From 48e428bbd741882119df6aba6b21637373e5dfac Mon Sep 17 00:00:00 2001 From: aredenba-rh Date: Wed, 20 May 2026 01:07:22 -0400 Subject: [PATCH 01/80] manage kg specs --- specs/extraction/agent-sessions.spec.md | 47 +++++++++++++ specs/extraction/operations.spec.md | 69 +++++++++++++++++++ specs/graph/mutations.spec.md | 15 ++++ specs/graph/schema-authoring.spec.md | 53 ++++++++++++++ specs/index.spec.md | 11 +++ .../knowledge-graph-workspace.spec.md | 60 ++++++++++++++++ specs/nfr/workload-execution.spec.md | 59 ++++++++++++++++ specs/ui/experience.spec.md | 60 ++++++++++++++++ 8 files changed, 374 insertions(+) create mode 100644 specs/extraction/agent-sessions.spec.md create mode 100644 specs/extraction/operations.spec.md create mode 100644 specs/graph/schema-authoring.spec.md create mode 100644 specs/management/knowledge-graph-workspace.spec.md create mode 100644 specs/nfr/workload-execution.spec.md diff --git a/specs/extraction/agent-sessions.spec.md b/specs/extraction/agent-sessions.spec.md new file mode 100644 index 000000000..020b1f0f7 --- /dev/null +++ b/specs/extraction/agent-sessions.spec.md @@ -0,0 +1,47 @@ +# Agent Sessions + +## Purpose +Agent sessions provide long-running conversational extraction workflows scoped to user, knowledge graph, and mode. Sessions remain active until explicitly cleared, while preserving auditable run history and metrics. + +## Requirements + +### Requirement: Session Scope +The system SHALL scope extraction agent sessions per user, knowledge graph, and mode. + +#### Scenario: Scope isolation +- GIVEN two users working on the same knowledge graph +- WHEN they open extraction agent sessions +- THEN each user receives a separate session +- AND session state is not shared across users + +#### Scenario: Mode isolation +- GIVEN a user session in bootstrap mode and a session in extraction mode +- WHEN both sessions exist for the same knowledge graph +- THEN each session keeps separate context and runtime state + +### Requirement: Long-Running Session Lifecycle +The system SHALL keep sessions active until explicit reset. + +#### Scenario: Persistent session context +- GIVEN an active extraction agent session +- WHEN the user sends follow-up messages over time +- THEN prior session context remains available for continued conversation + +### Requirement: Clear Chat Reset +The system SHALL provide an explicit "Clear chat" action that resets runtime context. + +#### Scenario: Full reset on clear +- GIVEN an active session with runtime context +- WHEN the user clicks "Clear chat" +- THEN message history and runtime context are reset +- AND a new clean session is started for that user/knowledge-graph/mode scope + +### Requirement: Session Archival and Retention +The system SHALL retain completed session and run records indefinitely. + +#### Scenario: Historical session visibility +- GIVEN prior sessions and mutation runs +- WHEN users or administrators query session history +- THEN archived sessions and associated run records remain available +- AND each record includes last-updated timestamps and run-level metrics + diff --git a/specs/extraction/operations.spec.md b/specs/extraction/operations.spec.md new file mode 100644 index 000000000..40110e1ee --- /dev/null +++ b/specs/extraction/operations.spec.md @@ -0,0 +1,69 @@ +# Operations + +## Purpose +Extraction operations define the mode-specific behaviors for schema bootstrap, extraction job setup, and minor direct edits. All write behavior is expressed as MutationLogs associated with a knowledge graph and session. + +## Requirements + +### Requirement: Mode-Specific Skill Sets +The system SHALL provide different default skill sets for bootstrap and extraction operations modes. + +#### Scenario: Bootstrap skills +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN an extraction agent session starts +- THEN the default skill set is schema-bootstrap oriented +- AND it prioritizes complete entity/relationship modeling and prepopulated instance coverage + +#### Scenario: Extraction skills +- GIVEN a knowledge graph in `extraction_operations` +- WHEN an extraction agent session starts +- THEN the default skill set is extraction-job-setup and minor-direct-edit oriented +- AND schema edit skills remain available but are not the primary framing + +### Requirement: Skill Resolution Model +The system SHALL resolve agent skills using global templates with knowledge-graph overrides. + +#### Scenario: Global template with override +- GIVEN a knowledge graph with custom skill overrides +- WHEN an extraction session resolves skill instructions +- THEN global skill templates are loaded first +- AND knowledge-graph overrides are applied on top + +### Requirement: Unified Extraction and Manual Edit Surface +The system SHALL provide one operational area for extraction jobs and minor direct graph edits. + +#### Scenario: Unified write path +- GIVEN a user in extraction operations mode +- WHEN the user runs extraction jobs or performs minor direct edits +- THEN both behaviors emit MutationLogs +- AND both target the same knowledge graph + +### Requirement: Validate-Then-Transition Workflow +The system SHALL gate transition from bootstrap mode through explicit validation and user action. + +#### Scenario: Validation gate +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN the user clicks Validate +- THEN validation results are returned and persisted +- AND transition remains unavailable until checks pass + +#### Scenario: Explicit transition action +- GIVEN validation has passed in `schema_bootstrap` +- WHEN the user clicks "Go to Extraction/Mutations" +- THEN the knowledge graph transitions to `extraction_operations` +- AND a new extraction-mode agent session is started + +### Requirement: MutationLog Session Association +The system SHALL associate MutationLogs with both knowledge graph and session/run identity. + +#### Scenario: Session-linked mutation runs +- GIVEN a session producing mutation operations +- WHEN MutationLogs are persisted +- THEN each log run stores session ID, knowledge graph ID, actor identity, and timestamps + +#### Scenario: Per-run operation metrics +- GIVEN a persisted mutation log run +- WHEN metrics are recorded +- THEN operation counts are captured by operation class (for example create/update for entity and relationship instances) +- AND token usage and cost metrics are captured for the run + diff --git a/specs/graph/mutations.spec.md b/specs/graph/mutations.spec.md index 50a90da5c..22dce6d9e 100644 --- a/specs/graph/mutations.spec.md +++ b/specs/graph/mutations.spec.md @@ -157,3 +157,18 @@ The system SHALL enforce correct ordering of operations to maintain referential - AND DELETE operations run next (edges before nodes) - AND CREATE operations follow (nodes before edges) - AND UPDATE operations run last + +### Requirement: MutationLog Run Metadata +The system SHALL persist run-level metadata for mutation logs. + +#### Scenario: Session and scope association +- GIVEN mutations produced by an extraction or manual-edit session +- WHEN the mutation log run is persisted +- THEN the run is associated with session ID and knowledge graph ID +- AND actor identity and run timestamps are recorded + +#### Scenario: Metrics capture +- GIVEN a persisted mutation log run +- WHEN run metrics are finalized +- THEN token usage and cost totals are stored +- AND operation counts are stored by operation class diff --git a/specs/graph/schema-authoring.spec.md b/specs/graph/schema-authoring.spec.md new file mode 100644 index 000000000..0505852ef --- /dev/null +++ b/specs/graph/schema-authoring.spec.md @@ -0,0 +1,53 @@ +# Schema Authoring + +## Purpose +Schema authoring defines how entity and relationship type definitions are created and evolved in the graph through mutation logs. It supports a bootstrap flow for first-time schema establishment and ongoing schema evolution during extraction operations. + +## Requirements + +### Requirement: Graph-Native Type Definitions +The system SHALL treat graph-stored type definitions as the canonical schema source. + +#### Scenario: Canonical storage +- GIVEN schema mutations are applied +- WHEN entity and relationship type definitions are persisted +- THEN canonical schema state is stored in the graph schema layer +- AND no parallel "design artifact" source of truth is required + +### Requirement: Bootstrap Authoring Flow +The system SHALL support schema authoring during `schema_bootstrap` mode through mutation logs. + +#### Scenario: Bootstrap schema creation +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN an agent or user creates entity and relationship types +- THEN changes are written via mutation logs +- AND resulting graph schema reflects those mutations + +#### Scenario: Capabilities-driven start +- GIVEN a new bootstrap session +- WHEN the schema agent starts +- THEN it asks for user capabilities/goals +- AND it offers two paths: an immediate first-pass schema attempt, or guided question-by-question co-design + +### Requirement: Ongoing Schema Evolution +The system SHALL allow schema updates during `extraction_operations` mode. + +#### Scenario: Additive schema change in extraction mode +- GIVEN a knowledge graph in `extraction_operations` +- WHEN a user or agent adds a new property or type +- THEN the change is accepted through mutation logs +- AND extraction operations continue using the updated schema + +### Requirement: Prepopulated Type Semantics +The system SHALL enforce `prepopulated=true` as a transition-blocking readiness constraint. + +#### Scenario: Prepopulated type with instances +- GIVEN a type marked `prepopulated=true` +- WHEN readiness is evaluated +- THEN the type passes only if it has one or more instances + +#### Scenario: Prepopulated type without instances +- GIVEN a type marked `prepopulated=true` with zero instances +- WHEN readiness is evaluated +- THEN validation fails and transition to extraction mode is blocked + diff --git a/specs/index.spec.md b/specs/index.spec.md index a28e9c70e..5cce73fa4 100644 --- a/specs/index.spec.md +++ b/specs/index.spec.md @@ -29,6 +29,7 @@ The persistence and query engine for property graph data. | [Mutations](graph/mutations.spec.md) | Applying mutation logs to the graph | | [Queries](graph/queries.spec.md) | Reading nodes, edges, and subgraphs | | [Schema](graph/schema.spec.md) | Type definitions and schema management | +| [Schema Authoring](graph/schema-authoring.spec.md) | Bootstrap and ongoing schema authoring lifecycle | | [Bulk Loading](graph/bulk-loading.spec.md) | High-throughput graph ingestion | ### [Management](management/) — Control Plane @@ -37,6 +38,7 @@ CRUD for platform resources: knowledge graphs, data sources, credentials. | Spec | Scope | |------|-------| | [Knowledge Graphs](management/knowledge-graphs.spec.md) | Knowledge graph configuration lifecycle | +| [Knowledge Graph Workspace](management/knowledge-graph-workspace.spec.md) | Knowledge graph mode lifecycle and workspace status | | [Data Sources](management/data-sources.spec.md) | Data source configuration and sync runs | | [Credentials](management/credentials.spec.md) | Encrypted credential storage | @@ -56,6 +58,14 @@ Connecting to external sources, detecting changes, and packaging raw content for | [Adapters](ingestion/adapters.spec.md) | Adapter port, GitHub adapter, dlt framework integration | | [Sync Lifecycle](ingestion/sync-lifecycle.spec.md) | Event-driven state machine, status tracking, staleness detection | +### [Extraction](extraction/) — Agent-Orchestrated Mutation Production +AI-assisted schema and extraction workflows that emit MutationLogs for Graph application. + +| Spec | Scope | +|------|-------| +| [Operations](extraction/operations.spec.md) | Mode-specific agent operations and mutation-log production | +| [Agent Sessions](extraction/agent-sessions.spec.md) | Session lifecycle, reset behavior, and session metrics | + ### [Shared Kernel](shared-kernel/) — Cross-Cutting Contracts Capabilities shared across bounded contexts. @@ -88,3 +98,4 @@ The web interface for platform setup, data source management, and graph explorat | [CORS](nfr/cors.spec.md) | Cross-origin resource sharing policy | | [Application Lifecycle](nfr/application-lifecycle.spec.md) | Startup bootstrap, shutdown, default configuration | | [API Conventions](nfr/api-conventions.spec.md) | URL structure, status codes, error format, request/response models | +| [Workload Execution](nfr/workload-execution.spec.md) | Container execution model, credential injection, and workload isolation | diff --git a/specs/management/knowledge-graph-workspace.spec.md b/specs/management/knowledge-graph-workspace.spec.md new file mode 100644 index 000000000..a3ed74b00 --- /dev/null +++ b/specs/management/knowledge-graph-workspace.spec.md @@ -0,0 +1,60 @@ +# Knowledge Graph Workspace + +## Purpose +A knowledge graph workspace provides a mode-aware control surface for progressing from initial schema bootstrap to ongoing extraction and mutation operations. It exposes lifecycle state, readiness checks, and navigation contracts consumed by the UI and extraction agents. + +## Requirements + +### Requirement: Workspace Mode Lifecycle +The system SHALL track each knowledge graph in one of two modes: `schema_bootstrap` and `extraction_operations`. + +#### Scenario: Default mode on creation +- GIVEN a newly created knowledge graph +- WHEN the knowledge graph record is persisted +- THEN its workspace mode is `schema_bootstrap` + +#### Scenario: Irreversible transition +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN the user completes validation and transitions to extraction operations +- THEN the mode changes to `extraction_operations` +- AND the mode cannot be changed back to `schema_bootstrap` + +### Requirement: Workspace Status Projection +The system SHALL expose a knowledge-graph workspace status projection for UI rendering. + +#### Scenario: Status includes mode and readiness +- GIVEN a knowledge graph workspace request +- WHEN the status projection is returned +- THEN it includes current mode, validation readiness flags, and a transition eligibility flag + +#### Scenario: Status includes session pointers +- GIVEN one or more extraction agent sessions associated with the knowledge graph +- WHEN the status projection is returned +- THEN it includes pointers to the current active session per mode and the most recent completed session + +### Requirement: Bootstrap Readiness Validation +The system SHALL define schema bootstrap readiness checks for transition eligibility. + +#### Scenario: Minimum schema readiness +- GIVEN a knowledge graph in `schema_bootstrap` +- WHEN readiness is evaluated +- THEN validation fails unless there is at least one entity type and at least one relationship type + +#### Scenario: Prepopulated instance readiness +- GIVEN one or more types marked `prepopulated=true` +- WHEN readiness is evaluated +- THEN validation fails if any such type has zero instances + +### Requirement: Transition Authorization +The system SHALL require `edit` permission on the knowledge graph for bootstrap validation and mode transition. + +#### Scenario: Authorized validate and transition +- GIVEN a user with `edit` permission on the knowledge graph +- WHEN the user invokes validate and transition actions +- THEN both actions are permitted + +#### Scenario: Unauthorized validate and transition +- GIVEN a user without `edit` permission on the knowledge graph +- WHEN the user invokes validate or transition actions +- THEN the action is rejected with a forbidden error + diff --git a/specs/nfr/workload-execution.spec.md b/specs/nfr/workload-execution.spec.md new file mode 100644 index 000000000..ce2149997 --- /dev/null +++ b/specs/nfr/workload-execution.spec.md @@ -0,0 +1,59 @@ +# Workload Execution + +NFR: This spec describes execution, isolation, and credential-injection constraints for agent workloads. + +## Purpose +Kartograph executes extraction agent workloads in containers with a hybrid model: sticky conversational containers per session and ephemeral worker containers for extraction execution. Runtime credentials are injected securely and scoped with least privilege. + +## Requirements + +### Requirement: Container-Only Agent Runtime +The system SHALL run extraction agents in containers for both local development and deployed environments. + +#### Scenario: Local development execution +- GIVEN local development workflows +- WHEN extraction agents are started +- THEN they run inside local containers rather than host-native processes + +#### Scenario: Deployed execution +- GIVEN a deployed environment +- WHEN extraction workloads are started +- THEN they run in pod containers managed by the platform + +### Requirement: Hybrid Container Model +The system SHALL use sticky containers for chat sessions and ephemeral containers for extraction execution workers. + +#### Scenario: Sticky session container +- GIVEN a user starts an extraction chat session +- WHEN the session remains active +- THEN the session reuses the same container context until clear/reset or timeout + +#### Scenario: Ephemeral execution workers +- GIVEN extraction jobs are launched +- WHEN worker tasks execute +- THEN they run in ephemeral worker containers +- AND worker containers are terminated after job completion or failure + +### Requirement: Runtime Credential Injection +The system SHALL provide runtime credentials to agent containers through secure injection. + +#### Scenario: Workload authentication material +- GIVEN a workload container requires access to platform services +- WHEN the workload starts +- THEN short-lived authentication credentials are injected at runtime +- AND credentials are not hardcoded in repository files, container images, or mutation logs + +#### Scenario: Least-privilege scope +- GIVEN an extraction workload for a knowledge graph +- WHEN credentials are issued +- THEN permissions are limited to required tenant and knowledge-graph scope operations + +### Requirement: Skill and Context Availability +The system SHALL provide required runtime context in workload containers. + +#### Scenario: Built-in context +- GIVEN an extraction workload container +- WHEN the workload initializes +- THEN ingestion context resources and repository files needed for processing are available +- AND the skills directory is available to the agent runtime + diff --git a/specs/ui/experience.spec.md b/specs/ui/experience.spec.md index eb43171e4..373d727e7 100644 --- a/specs/ui/experience.spec.md +++ b/specs/ui/experience.spec.md @@ -511,3 +511,63 @@ The system SHALL support light and dark color schemes. - GIVEN the user interface - THEN a dark mode toggle is available in the header - AND the preference persists across sessions + +### Requirement: Knowledge Graph Manage Actions +The system SHALL expose knowledge graph row actions as Manage, Query, and Delete. + +#### Scenario: Knowledge graph action set +- GIVEN the knowledge graph list +- THEN each knowledge graph row shows actions for Manage, Query, and Delete +- AND legacy actions not in this set are not shown in the row action cluster + +#### Scenario: Manage navigation +- GIVEN a user clicks Manage on a knowledge graph row +- WHEN navigation completes +- THEN the user lands on that knowledge graph's mode-aware workspace page + +### Requirement: Bootstrap to Extraction Transition +The system SHALL provide a UI-gated transition from schema bootstrap mode to extraction operations mode. + +#### Scenario: Validate action +- GIVEN a user with `edit` permission on a knowledge graph in bootstrap mode +- WHEN the user clicks Validate +- THEN validation results are displayed in the workspace +- AND transition action remains unavailable until validation passes + +#### Scenario: Go to extraction action +- GIVEN bootstrap validation has passed +- WHEN the user clicks "Go to Extraction/Mutations" +- THEN the UI transitions the knowledge graph into extraction operations mode +- AND a new extraction-mode agent session is started + +### Requirement: Unified Extraction Workspace +The system SHALL present extraction jobs and minor direct edits in one workspace. + +#### Scenario: Conversation-first layout +- GIVEN a user in extraction operations mode +- THEN the conversation panel remains visible as the primary surface +- AND the lower workspace area is tabbed for operational views + +#### Scenario: Clear chat reset +- GIVEN an active extraction conversation session +- WHEN the user clicks Clear chat +- THEN the current chat history is cleared +- AND a new clean session is started for the same user and knowledge graph + +#### Scenario: Tabbed operations area +- GIVEN the extraction workspace +- WHEN the user switches tabs +- THEN extraction-job controls, manual mutation tools, and run/log views are available without leaving the page + +### Requirement: MutationLog Browser +The system SHALL provide a knowledge-graph-scoped MutationLog browser. + +#### Scenario: Scoped listing +- GIVEN the user is viewing a specific knowledge graph +- WHEN the user opens MutationLogs +- THEN only mutation log runs associated with that knowledge graph are listed + +#### Scenario: Run detail panel +- GIVEN a mutation log run is selected +- WHEN details are shown +- THEN the UI displays run summary, per-entry operation previews, token/cost metrics, and operation counts by type From fcdbe4dd1b8b7ab2bdc3bf77f81b75fef9644250 Mon Sep 17 00:00:00 2001 From: aredenba-rh Date: Wed, 20 May 2026 01:31:53 -0400 Subject: [PATCH 02/80] minor edits to specs; github issues created --- specs/ingestion/sync-lifecycle.spec.md | 22 +++++++++++++++++++ specs/management/data-sources.spec.md | 30 ++++++++++++++++++++++++++ specs/ui/experience.spec.md | 18 ++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/specs/ingestion/sync-lifecycle.spec.md b/specs/ingestion/sync-lifecycle.spec.md index c2713ec96..07fe77bef 100644 --- a/specs/ingestion/sync-lifecycle.spec.md +++ b/specs/ingestion/sync-lifecycle.spec.md @@ -70,6 +70,28 @@ The system SHALL support both manual and scheduled sync triggers. - WHEN the schedule fires - THEN a sync is initiated as if manually triggered +### Requirement: Commit-Baseline-Aware Ingestion +The system SHALL maintain commit-aware ingestion context for Git-backed sources. + +#### Scenario: Baseline at extraction start +- GIVEN a Git-backed data source with a local clone +- WHEN a sync run starts +- THEN the run baseline is set to `commit_during_last_extraction` +- AND incremental extraction compares current source state against that baseline + +#### Scenario: Branch head refresh for ingestion readiness +- GIVEN a Git-backed data source with a tracked branch +- WHEN sync orchestration prepares ingestion context +- THEN the latest tracked branch HEAD is resolved and stored as `tracked_branch_head_commit` +- AND ingestion context for that run is prepared from the corresponding latest files + +#### Scenario: No-new-commit outcome +- GIVEN `tracked_branch_head_commit` equals `commit_during_last_extraction` +- WHEN a sync run is requested +- THEN the system may short-circuit heavy extraction work +- AND a sync run record is still created for auditability +- AND run status and logs indicate no source changes were detected + ### Requirement: Staleness-Based Node Lifecycle The system SHALL use timestamp comparison to detect stale graph nodes instead of explicit delete events. diff --git a/specs/management/data-sources.spec.md b/specs/management/data-sources.spec.md index fe056dceb..da21a5ce3 100644 --- a/specs/management/data-sources.spec.md +++ b/specs/management/data-sources.spec.md @@ -126,6 +126,36 @@ The system SHALL track the execution status of each sync operation. - WHEN the data source is deleted - THEN all associated sync runs are cascade-deleted +### Requirement: Source Commit Reference Tracking +The system SHALL track source-repository commit references for Git-based data sources. + +#### Scenario: Local clone commit tracking +- GIVEN a Git-backed data source with a local clone available to ingestion tooling +- WHEN source commit references are refreshed +- THEN a clone-head commit reference is recorded as the ingestion clone HEAD for the tracked branch + +#### Scenario: Commit during last extraction tracking +- GIVEN a sync run starts for a Git-backed data source +- WHEN extraction begins +- THEN a last-extraction baseline commit reference is recorded from local clone state at run start +- AND this value remains fixed for that run even if branch HEAD changes later + +#### Scenario: Tracked branch head commit tracking +- GIVEN a Git-backed data source configured with a tracked branch +- WHEN source commit references are refreshed +- THEN a tracked-branch head commit reference is recorded from the latest known remote branch HEAD + +#### Scenario: UI label compatibility +- GIVEN commit references are displayed in the UI +- WHEN labels are rendered +- THEN labels may use either legacy terms ("Local clone commit", "Commit during last extraction") or clearer equivalents +- AND displayed labels map unambiguously to clone-head, last-extraction-baseline, and tracked-branch-head references + +#### Scenario: Adapter scope +- GIVEN a non-Git adapter type +- WHEN source commit references are requested +- THEN Git-specific commit fields are absent or null + ### Requirement: Adapter Connection Config Normalization Each adapter SHALL accept user-friendly connection parameters and normalize them internally. diff --git a/specs/ui/experience.spec.md b/specs/ui/experience.spec.md index 373d727e7..dd1f091d1 100644 --- a/specs/ui/experience.spec.md +++ b/specs/ui/experience.spec.md @@ -144,6 +144,24 @@ The system SHALL show sync progress and status for each data source. - WHEN the user triggers a sync - THEN a new sync run begins and progress is shown +#### Scenario: Commit-hash status cues +- GIVEN a Git-backed data source card in the UI +- WHEN commit reference data is available +- THEN the UI displays `Local clone commit`, `Commit during last extraction`, and tracked branch head commit values +- AND the UI visually indicates whether new commits are available since the last extraction baseline + +#### Scenario: Maintenance-readiness cue +- GIVEN a Git-backed data source where tracked branch head differs from commit during last extraction +- WHEN the user views data source status +- THEN the UI highlights that maintenance/extraction work can be run for new source changes + +#### Scenario: Diff summary cue +- GIVEN a Git-backed data source with commit references for baseline and latest tracked branch head +- WHEN the user opens sync/maintenance details +- THEN the UI shows a diff summary relative to the last extraction baseline suitable for deciding whether to run maintenance +- AND the summary includes aggregate counts and a changed-file list +- AND the changed-file list is collapsed by default and expanded on demand to avoid overwhelming the page + ### Requirement: Get Started Querying (MCP Connection) The system SHALL make it easy for users to connect AI agents to their knowledge graph via MCP. From d48c322673e768de69e7fb0f42f7989d25a05510 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 01:52:10 -0400 Subject: [PATCH 03/80] feat(management): add workspace mode lifecycle (#643) (#680) * chore(skills): add subagent delivery execution protocol Add a reusable subagent skill that standardizes issue-based branching, TDD execution, PR structure, and merge/conflict handling into feature/manage-knowledge-graph. Co-authored-by: Cursor * feat(management): add knowledge graph workspace mode lifecycle Implement schema_bootstrap as the default workspace mode and persist irreversible transition state to extraction_operations across domain, repository, API responses, and migration coverage. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- skills/subagent-delivery/SKILL.md | 83 +++++++++++++++++++ ..._add_workspace_mode_to_knowledge_graphs.py | 39 +++++++++ .../domain/aggregates/knowledge_graph.py | 18 +++- src/api/management/domain/exceptions.py | 6 ++ src/api/management/domain/value_objects.py | 7 ++ .../infrastructure/models/knowledge_graph.py | 7 ++ .../knowledge_graph_repository.py | 9 +- .../presentation/knowledge_graphs/models.py | 6 ++ .../test_knowledge_graph_repository.py | 27 ++++++ .../test_knowledge_graphs_routes.py | 7 +- .../unit/management/test_knowledge_graph.py | 36 +++++++- 11 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 skills/subagent-delivery/SKILL.md create mode 100644 src/api/infrastructure/migrations/versions/f4a5b6c7d8e9_add_workspace_mode_to_knowledge_graphs.py diff --git a/skills/subagent-delivery/SKILL.md b/skills/subagent-delivery/SKILL.md new file mode 100644 index 000000000..0273dba82 --- /dev/null +++ b/skills/subagent-delivery/SKILL.md @@ -0,0 +1,83 @@ +--- +name: subagent-delivery +description: > + Executes a GitHub issue end-to-end with consistent branch, test, PR, and merge behavior. + Use when implementing units of work with sub-agents, preparing pull requests, resolving merge + conflicts, or when the user asks to run issue-by-issue delivery into feature/manage-knowledge-graph. +--- + +# Subagent Delivery Protocol + +Follow this protocol for every assigned issue. + +## Scope and Inputs + +Before coding, gather: + +1. Issue number and acceptance criteria. +2. Target branch: `feature/manage-knowledge-graph`. +3. Current repository state (`git status`, `git branch -vv`). + +If acceptance criteria are ambiguous, ask one focused question before implementation. + +## Git Workflow + +1. Ensure local target branch is up to date: + - `git checkout feature/manage-knowledge-graph` + - `git pull --ff-only` +2. Create a dedicated branch per issue: + - `feat/issue--` for features + - `fix/issue--` for fixes +3. Never mix multiple issues in one branch. +4. Keep commits atomic and conventional (`feat:`, `fix:`, `refactor:`, `test:`). + +## Implementation Workflow (TDD Required) + +1. Read relevant spec(s) and affected bounded context code first. +2. Write/adjust tests for expected behavior before implementation. +3. Implement minimal code to satisfy tests. +4. Run focused tests first, then broader suite for touched context. +5. Run lints/type checks for changed files when applicable. +6. If behavior depends on configuration, use settings/DI instead of hardcoding. + +## PR Workflow + +1. Push branch to origin with upstream tracking. +2. Open PR against `feature/manage-knowledge-graph`. +3. Use this body structure: + +```markdown +## Summary +- +- + +## Testing +- [x] +- [x] +- [ ] + +## Risks +- or +``` + +4. Link the issue in PR body using `Closes #` when appropriate. + +## Merge and Conflict Handling + +1. Before merge, ensure CI checks are green. +2. If branch is stale, rebase or merge target branch cleanly. +3. Resolve conflicts preserving: + - Spec-required behavior + - Existing user changes + - Authorization and tenancy boundaries +4. Re-run tests after conflict resolution. +5. Merge into `feature/manage-knowledge-graph` only after verification. + +## Non-Negotiables + +- Do not use destructive git commands. +- Do not skip tests. +- Do not disable hooks. +- Do not commit secrets or credentials. +- Prefer fakes over mocks in unit tests when testing domain/application behavior. + diff --git a/src/api/infrastructure/migrations/versions/f4a5b6c7d8e9_add_workspace_mode_to_knowledge_graphs.py b/src/api/infrastructure/migrations/versions/f4a5b6c7d8e9_add_workspace_mode_to_knowledge_graphs.py new file mode 100644 index 000000000..98ef99082 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/f4a5b6c7d8e9_add_workspace_mode_to_knowledge_graphs.py @@ -0,0 +1,39 @@ +"""add workspace_mode to knowledge_graphs + +Adds lifecycle mode tracking to KnowledgeGraph records with a non-null +default of ``schema_bootstrap``. + +Revision ID: f4a5b6c7d8e9 +Revises: e2f3a4b5c6d7 +Create Date: 2026-05-14 12:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "f4a5b6c7d8e9" +down_revision: Union[str, Sequence[str], None] = "e2f3a4b5c6d7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add workspace_mode with bootstrap default for existing rows.""" + op.add_column( + "knowledge_graphs", + sa.Column( + "workspace_mode", + sa.String(length=64), + nullable=False, + server_default="schema_bootstrap", + ), + ) + + +def downgrade() -> None: + """Drop workspace_mode from knowledge_graphs.""" + op.drop_column("knowledge_graphs", "workspace_mode") diff --git a/src/api/management/domain/aggregates/knowledge_graph.py b/src/api/management/domain/aggregates/knowledge_graph.py index 63d10cefb..542d6eebd 100644 --- a/src/api/management/domain/aggregates/knowledge_graph.py +++ b/src/api/management/domain/aggregates/knowledge_graph.py @@ -15,12 +15,17 @@ AggregateDeletedError, InvalidIdentifierError, InvalidKnowledgeGraphNameError, + InvalidWorkspaceModeTransitionError, ) from management.domain.observability import ( DefaultKnowledgeGraphProbe, KnowledgeGraphProbe, ) -from management.domain.value_objects import KnowledgeGraphId, OntologyConfig +from management.domain.value_objects import ( + KnowledgeGraphId, + OntologyConfig, + WorkspaceMode, +) if TYPE_CHECKING: from management.domain.events import DomainEvent @@ -51,6 +56,7 @@ class KnowledgeGraph: created_at: datetime updated_at: datetime ontology: OntologyConfig | None = field(default=None) + workspace_mode: WorkspaceMode = field(default=WorkspaceMode.SCHEMA_BOOTSTRAP) _pending_events: list[DomainEvent] = field(default_factory=list, repr=False) _probe: KnowledgeGraphProbe = field( default_factory=DefaultKnowledgeGraphProbe, @@ -63,6 +69,7 @@ def __post_init__(self) -> None: self._validate_name(self.name) self._validate_identifier(self.tenant_id, "tenant_id") self._validate_identifier(self.workspace_id, "workspace_id") + self.workspace_mode = WorkspaceMode(self.workspace_mode) def _validate_name(self, name: str) -> None: """Validate knowledge graph name length. @@ -230,6 +237,15 @@ def clear_ontology(self) -> None: self.ontology = None self.updated_at = datetime.now(UTC) + def transition_to_extraction_operations(self) -> None: + """Transition workspace mode from bootstrap to extraction operations.""" + if self.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS: + raise InvalidWorkspaceModeTransitionError( + "Workspace mode is already extraction_operations" + ) + self.workspace_mode = WorkspaceMode.EXTRACTION_OPERATIONS + self.updated_at = datetime.now(UTC) + def mark_for_deletion( self, *, diff --git a/src/api/management/domain/exceptions.py b/src/api/management/domain/exceptions.py index 3f225fdf2..06b4f35a7 100644 --- a/src/api/management/domain/exceptions.py +++ b/src/api/management/domain/exceptions.py @@ -29,3 +29,9 @@ class InvalidIdentifierError(Exception): """Raised when a cross-context identifier (tenant_id, workspace_id, etc.) is empty or whitespace.""" pass + + +class InvalidWorkspaceModeTransitionError(Exception): + """Raised when a workspace mode transition is invalid.""" + + pass diff --git a/src/api/management/domain/value_objects.py b/src/api/management/domain/value_objects.py index 0fc20b7ab..eddf2dedb 100644 --- a/src/api/management/domain/value_objects.py +++ b/src/api/management/domain/value_objects.py @@ -94,6 +94,13 @@ class ScheduleType(StrEnum): INTERVAL = "interval" +class WorkspaceMode(StrEnum): + """Lifecycle mode of a knowledge-graph workspace.""" + + SCHEMA_BOOTSTRAP = "schema_bootstrap" + EXTRACTION_OPERATIONS = "extraction_operations" + + @dataclass(frozen=True) class Schedule: """Schedule configuration for data source synchronization. diff --git a/src/api/management/infrastructure/models/knowledge_graph.py b/src/api/management/infrastructure/models/knowledge_graph.py index 36a1d70bd..51125f721 100644 --- a/src/api/management/infrastructure/models/knowledge_graph.py +++ b/src/api/management/infrastructure/models/knowledge_graph.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column from infrastructure.database.models import Base, TimestampMixin +from management.domain.value_objects import WorkspaceMode class KnowledgeGraphModel(Base, TimestampMixin): @@ -30,6 +31,12 @@ class KnowledgeGraphModel(Base, TimestampMixin): workspace_id: Mapped[str] = mapped_column(String(26), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(sa.Text, nullable=False) + workspace_mode: Mapped[str] = mapped_column( + String(64), + nullable=False, + default=WorkspaceMode.SCHEMA_BOOTSTRAP.value, + server_default=WorkspaceMode.SCHEMA_BOOTSTRAP.value, + ) ontology: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None) __table_args__ = ( diff --git a/src/api/management/infrastructure/repositories/knowledge_graph_repository.py b/src/api/management/infrastructure/repositories/knowledge_graph_repository.py index abb5aff83..432ad7699 100644 --- a/src/api/management/infrastructure/repositories/knowledge_graph_repository.py +++ b/src/api/management/infrastructure/repositories/knowledge_graph_repository.py @@ -13,7 +13,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from management.domain.aggregates import KnowledgeGraph -from management.domain.value_objects import KnowledgeGraphId, OntologyConfig +from management.domain.value_objects import ( + KnowledgeGraphId, + OntologyConfig, + WorkspaceMode, +) from management.infrastructure.models import KnowledgeGraphModel from management.infrastructure.observability import ( DefaultKnowledgeGraphRepositoryProbe, @@ -67,6 +71,7 @@ async def save(self, knowledge_graph: KnowledgeGraph) -> None: if model: model.name = knowledge_graph.name model.description = knowledge_graph.description + model.workspace_mode = knowledge_graph.workspace_mode.value model.updated_at = knowledge_graph.updated_at else: model = KnowledgeGraphModel( @@ -75,6 +80,7 @@ async def save(self, knowledge_graph: KnowledgeGraph) -> None: workspace_id=knowledge_graph.workspace_id, name=knowledge_graph.name, description=knowledge_graph.description, + workspace_mode=knowledge_graph.workspace_mode.value, created_at=knowledge_graph.created_at, updated_at=knowledge_graph.updated_at, ) @@ -219,4 +225,5 @@ def _to_domain(self, model: KnowledgeGraphModel) -> KnowledgeGraph: created_at=model.created_at, updated_at=model.updated_at, ontology=ontology, + workspace_mode=WorkspaceMode(model.workspace_mode), ) diff --git a/src/api/management/presentation/knowledge_graphs/models.py b/src/api/management/presentation/knowledge_graphs/models.py index 4594c6427..eccffc525 100644 --- a/src/api/management/presentation/knowledge_graphs/models.py +++ b/src/api/management/presentation/knowledge_graphs/models.py @@ -11,6 +11,7 @@ EdgeTypeDefinition, NodeTypeDefinition, OntologyConfig, + WorkspaceMode, ) @@ -71,6 +72,10 @@ class KnowledgeGraphResponse(BaseModel): workspace_id: str = Field(..., description="Workspace ID this KG belongs to") name: str = Field(..., description="Knowledge graph name") description: str = Field(..., description="Knowledge graph description") + workspace_mode: WorkspaceMode = Field( + ..., + description="Workspace lifecycle mode for this knowledge graph", + ) created_at: datetime = Field(..., description="When the KG was created") updated_at: datetime = Field(..., description="When the KG was last updated") @@ -90,6 +95,7 @@ def from_domain(cls, kg: KnowledgeGraph) -> KnowledgeGraphResponse: workspace_id=kg.workspace_id, name=kg.name, description=kg.description, + workspace_mode=kg.workspace_mode, created_at=kg.created_at, updated_at=kg.updated_at, ) diff --git a/src/api/tests/integration/management/test_knowledge_graph_repository.py b/src/api/tests/integration/management/test_knowledge_graph_repository.py index 66cac6197..d7e6b7c56 100644 --- a/src/api/tests/integration/management/test_knowledge_graph_repository.py +++ b/src/api/tests/integration/management/test_knowledge_graph_repository.py @@ -22,6 +22,7 @@ KnowledgeGraphRepository, ) from management.ports.exceptions import DuplicateKnowledgeGraphNameError +from management.domain.value_objects import WorkspaceMode from shared_kernel.datasource_types import DataSourceAdapterType pytestmark = pytest.mark.integration @@ -84,6 +85,32 @@ async def test_saves_and_retrieves_with_description( assert retrieved is not None assert retrieved.description == "" + @pytest.mark.asyncio + async def test_saves_and_retrieves_workspace_mode( + self, + knowledge_graph_repository: KnowledgeGraphRepository, + async_session, + test_tenant: str, + test_workspace: str, + clean_management_data, + ): + """Should persist workspace mode transition state.""" + kg = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Workspace Mode KG", + description="Tracks mode lifecycle", + ) + kg.transition_to_extraction_operations() + + async with async_session.begin(): + await knowledge_graph_repository.save(kg) + + retrieved = await knowledge_graph_repository.get_by_id(kg.id) + + assert retrieved is not None + assert retrieved.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS + class TestKnowledgeGraphUpdate: """Tests for updating knowledge graphs.""" diff --git a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py index 4c5e6c009..31806882e 100644 --- a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py +++ b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py @@ -19,7 +19,7 @@ KnowledgeGraphService, ) from management.domain.aggregates import KnowledgeGraph -from management.domain.value_objects import KnowledgeGraphId +from management.domain.value_objects import KnowledgeGraphId, WorkspaceMode from management.ports.exceptions import ( DuplicateKnowledgeGraphNameError, KnowledgeGraphNotFoundError, @@ -100,6 +100,10 @@ def test_list_knowledge_graphs_returns_200( assert len(result["knowledge_graphs"]) == 1 assert result["knowledge_graphs"][0]["id"] == sample_knowledge_graph.id.value assert result["knowledge_graphs"][0]["name"] == sample_knowledge_graph.name + assert ( + result["knowledge_graphs"][0]["workspace_mode"] + == WorkspaceMode.SCHEMA_BOOTSTRAP.value + ) def test_list_knowledge_graphs_calls_list_all_with_view_permission_by_default( self, @@ -254,6 +258,7 @@ def test_get_knowledge_graph_returns_200( assert result["description"] == sample_knowledge_graph.description assert result["tenant_id"] == sample_knowledge_graph.tenant_id assert result["workspace_id"] == sample_knowledge_graph.workspace_id + assert result["workspace_mode"] == WorkspaceMode.SCHEMA_BOOTSTRAP.value def test_get_knowledge_graph_calls_service_with_user_id( self, diff --git a/src/api/tests/unit/management/test_knowledge_graph.py b/src/api/tests/unit/management/test_knowledge_graph.py index 01ae468f3..c30d5e0b0 100644 --- a/src/api/tests/unit/management/test_knowledge_graph.py +++ b/src/api/tests/unit/management/test_knowledge_graph.py @@ -17,11 +17,12 @@ AggregateDeletedError, InvalidIdentifierError, InvalidKnowledgeGraphNameError, + InvalidWorkspaceModeTransitionError, ) from management.domain.observability import ( KnowledgeGraphProbe, ) -from management.domain.value_objects import KnowledgeGraphId +from management.domain.value_objects import KnowledgeGraphId, WorkspaceMode class TestKnowledgeGraphCreate: @@ -43,6 +44,7 @@ def test_create_sets_all_fields(self): assert isinstance(kg.created_at, datetime) assert isinstance(kg.updated_at, datetime) assert kg.created_at == kg.updated_at + assert kg.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP def test_create_generates_unique_id(self): """Each create() call should generate a unique ID.""" @@ -219,6 +221,38 @@ def test_update_raises_after_deletion(self): kg.update(name="Should fail", description="") +class TestKnowledgeGraphWorkspaceMode: + """Tests for workspace mode lifecycle transitions.""" + + def _create_kg(self, **kwargs): + defaults = { + "tenant_id": "t", + "workspace_id": "w", + "name": "Original", + "description": "Original desc", + } + defaults.update(kwargs) + kg = KnowledgeGraph.create(**defaults) + kg.collect_events() + return kg + + def test_transition_to_extraction_operations(self): + """Transition should move mode to extraction_operations.""" + kg = self._create_kg() + + kg.transition_to_extraction_operations() + + assert kg.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS + + def test_transition_is_irreversible(self): + """Transitioning after extraction_operations should fail.""" + kg = self._create_kg() + kg.transition_to_extraction_operations() + + with pytest.raises(InvalidWorkspaceModeTransitionError): + kg.transition_to_extraction_operations() + + class TestKnowledgeGraphMarkForDeletion: """Tests for KnowledgeGraph.mark_for_deletion() method.""" From 75d58e5b8cf5eb3cfecd455e298de8191ecb2abb Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 01:55:35 -0400 Subject: [PATCH 04/80] feat(management): expose knowledge graph workspace status projection (#681) Add a workspace-status API projection with mode, readiness flags, transition eligibility, and session pointers, including service and route authorization coverage for manage workspace rendering. Co-authored-by: Cursor --- .../services/knowledge_graph_service.py | 55 +++++++++++++- src/api/management/domain/value_objects.py | 38 ++++++++++ .../presentation/knowledge_graphs/models.py | 63 ++++++++++++++++ .../presentation/knowledge_graphs/routes.py | 38 ++++++++++ .../test_knowledge_graph_service.py | 75 +++++++++++++++++++ .../test_knowledge_graphs_routes.py | 66 +++++++++++++++- 6 files changed, 333 insertions(+), 2 deletions(-) diff --git a/src/api/management/application/services/knowledge_graph_service.py b/src/api/management/application/services/knowledge_graph_service.py index e32bac3b7..2de278904 100644 --- a/src/api/management/application/services/knowledge_graph_service.py +++ b/src/api/management/application/services/knowledge_graph_service.py @@ -14,7 +14,14 @@ KnowledgeGraphServiceProbe, ) from management.domain.aggregates import KnowledgeGraph -from management.domain.value_objects import KnowledgeGraphId, OntologyConfig +from management.domain.value_objects import ( + KnowledgeGraphId, + KnowledgeGraphWorkspaceStatus, + OntologyConfig, + WorkspaceMode, + WorkspaceReadinessStatus, + WorkspaceSessionPointers, +) from management.ports.exceptions import ( DuplicateKnowledgeGraphNameError, KnowledgeGraphNotFoundError, @@ -580,3 +587,49 @@ async def save_ontology( await self._session.commit() return config + + def _evaluate_workspace_readiness( + self, kg: KnowledgeGraph + ) -> WorkspaceReadinessStatus: + """Evaluate transition readiness flags for workspace status projection.""" + node_type_count = len(kg.ontology.node_types) if kg.ontology else 0 + edge_type_count = len(kg.ontology.edge_types) if kg.ontology else 0 + + # Prepopulated-instance validation is delivered by later units of work. + return WorkspaceReadinessStatus( + has_minimum_entity_types=node_type_count >= 1, + has_minimum_relationship_types=edge_type_count >= 1, + prepopulated_types_ready=True, + ) + + async def get_workspace_status( + self, + user_id: str, + kg_id: str, + ) -> KnowledgeGraphWorkspaceStatus | None: + """Get mode/readiness/session projection for a knowledge graph workspace.""" + kg = await self._kg_repo.get_by_id(KnowledgeGraphId(value=kg_id)) + if kg is None or kg.tenant_id != self._scope_to_tenant: + return None + + has_view = await self._check_permission( + user_id=user_id, + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=kg_id, + permission=Permission.VIEW, + ) + if not has_view: + return None + + readiness = self._evaluate_workspace_readiness(kg) + transition_eligible = ( + kg.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP and readiness.is_ready + ) + + return KnowledgeGraphWorkspaceStatus( + knowledge_graph_id=kg.id.value, + workspace_mode=kg.workspace_mode, + readiness=readiness, + transition_eligible=transition_eligible, + session_pointers=WorkspaceSessionPointers(), + ) diff --git a/src/api/management/domain/value_objects.py b/src/api/management/domain/value_objects.py index eddf2dedb..415d6a40e 100644 --- a/src/api/management/domain/value_objects.py +++ b/src/api/management/domain/value_objects.py @@ -101,6 +101,44 @@ class WorkspaceMode(StrEnum): EXTRACTION_OPERATIONS = "extraction_operations" +@dataclass(frozen=True) +class WorkspaceReadinessStatus: + """Readiness flags used to determine bootstrap transition eligibility.""" + + has_minimum_entity_types: bool + has_minimum_relationship_types: bool + prepopulated_types_ready: bool + + @property + def is_ready(self) -> bool: + """Return true when all readiness checks pass.""" + return ( + self.has_minimum_entity_types + and self.has_minimum_relationship_types + and self.prepopulated_types_ready + ) + + +@dataclass(frozen=True) +class WorkspaceSessionPointers: + """Session pointers projected for workspace status UIs.""" + + active_schema_bootstrap_session_id: str | None = None + active_extraction_operations_session_id: str | None = None + most_recent_completed_session_id: str | None = None + + +@dataclass(frozen=True) +class KnowledgeGraphWorkspaceStatus: + """Workspace status projection for a knowledge graph.""" + + knowledge_graph_id: str + workspace_mode: WorkspaceMode + readiness: WorkspaceReadinessStatus + transition_eligible: bool + session_pointers: WorkspaceSessionPointers + + @dataclass(frozen=True) class Schedule: """Schedule configuration for data source synchronization. diff --git a/src/api/management/presentation/knowledge_graphs/models.py b/src/api/management/presentation/knowledge_graphs/models.py index eccffc525..7ca060a1c 100644 --- a/src/api/management/presentation/knowledge_graphs/models.py +++ b/src/api/management/presentation/knowledge_graphs/models.py @@ -9,8 +9,11 @@ from management.domain.aggregates import KnowledgeGraph from management.domain.value_objects import ( EdgeTypeDefinition, + KnowledgeGraphWorkspaceStatus, NodeTypeDefinition, OntologyConfig, + WorkspaceReadinessStatus, + WorkspaceSessionPointers, WorkspaceMode, ) @@ -101,6 +104,66 @@ def from_domain(cls, kg: KnowledgeGraph) -> KnowledgeGraphResponse: ) +class WorkspaceReadinessResponse(BaseModel): + """Workspace readiness flags for bootstrap transition.""" + + has_minimum_entity_types: bool + has_minimum_relationship_types: bool + prepopulated_types_ready: bool + + @classmethod + def from_domain(cls, readiness: WorkspaceReadinessStatus) -> "WorkspaceReadinessResponse": + return cls( + has_minimum_entity_types=readiness.has_minimum_entity_types, + has_minimum_relationship_types=readiness.has_minimum_relationship_types, + prepopulated_types_ready=readiness.prepopulated_types_ready, + ) + + +class WorkspaceSessionPointersResponse(BaseModel): + """Session pointer projection for workspace status UI.""" + + active_schema_bootstrap_session_id: str | None = None + active_extraction_operations_session_id: str | None = None + most_recent_completed_session_id: str | None = None + + @classmethod + def from_domain( + cls, pointers: WorkspaceSessionPointers + ) -> "WorkspaceSessionPointersResponse": + return cls( + active_schema_bootstrap_session_id=pointers.active_schema_bootstrap_session_id, + active_extraction_operations_session_id=( + pointers.active_extraction_operations_session_id + ), + most_recent_completed_session_id=pointers.most_recent_completed_session_id, + ) + + +class KnowledgeGraphWorkspaceStatusResponse(BaseModel): + """Mode/readiness/session status projection for a knowledge graph workspace.""" + + knowledge_graph_id: str + workspace_mode: WorkspaceMode + readiness: WorkspaceReadinessResponse + transition_eligible: bool + session_pointers: WorkspaceSessionPointersResponse + + @classmethod + def from_domain( + cls, status: KnowledgeGraphWorkspaceStatus + ) -> "KnowledgeGraphWorkspaceStatusResponse": + return cls( + knowledge_graph_id=status.knowledge_graph_id, + workspace_mode=status.workspace_mode, + readiness=WorkspaceReadinessResponse.from_domain(status.readiness), + transition_eligible=status.transition_eligible, + session_pointers=WorkspaceSessionPointersResponse.from_domain( + status.session_pointers + ), + ) + + # --------------------------------------------------------------------------- # Ontology models # --------------------------------------------------------------------------- diff --git a/src/api/management/presentation/knowledge_graphs/routes.py b/src/api/management/presentation/knowledge_graphs/routes.py index 3f9ca0524..abe645432 100644 --- a/src/api/management/presentation/knowledge_graphs/routes.py +++ b/src/api/management/presentation/knowledge_graphs/routes.py @@ -21,6 +21,7 @@ CreateKnowledgeGraphRequest, KnowledgeGraphListResponse, KnowledgeGraphResponse, + KnowledgeGraphWorkspaceStatusResponse, OntologyConfigRequest, OntologyConfigResponse, UpdateKnowledgeGraphRequest, @@ -156,6 +157,43 @@ async def get_knowledge_graph( ) +@router.get( + "/knowledge-graphs/{kg_id}/workspace-status", + response_model=KnowledgeGraphWorkspaceStatusResponse, + summary="Get knowledge graph workspace status projection", + description=""" +Return mode/readiness/session status used by the knowledge graph Manage workspace UI. + +Returns 404 when the knowledge graph does not exist or the caller lacks `view` +permission on the knowledge graph. +""", +) +async def get_knowledge_graph_workspace_status( + kg_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)], +) -> KnowledgeGraphWorkspaceStatusResponse: + """Get workspace status projection for a knowledge graph.""" + try: + status_projection = await service.get_workspace_status( + user_id=current_user.user_id.value, + kg_id=kg_id, + ) + if status_projection is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Knowledge graph {kg_id} not found", + ) + return KnowledgeGraphWorkspaceStatusResponse.from_domain(status_projection) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve workspace status", + ) + + @router.post( "/workspaces/{workspace_id}/knowledge-graphs", status_code=status.HTTP_201_CREATED, diff --git a/src/api/tests/unit/management/application/test_knowledge_graph_service.py b/src/api/tests/unit/management/application/test_knowledge_graph_service.py index 423e2e510..8b0264ea0 100644 --- a/src/api/tests/unit/management/application/test_knowledge_graph_service.py +++ b/src/api/tests/unit/management/application/test_knowledge_graph_service.py @@ -21,9 +21,14 @@ from management.domain.aggregates import DataSource, KnowledgeGraph from management.domain.value_objects import ( DataSourceId, + EdgeTypeDefinition, + KnowledgeGraphWorkspaceStatus, KnowledgeGraphId, + NodeTypeDefinition, + OntologyConfig, Schedule, ScheduleType, + WorkspaceMode, ) from shared_kernel.datasource_types import DataSourceAdapterType from management.ports.exceptions import ( @@ -410,6 +415,76 @@ async def test_get_returns_aggregate_on_success( assert probe.knowledge_graph_retrieved_calls[0]["kg_id"] == kg.id.value +class TestKnowledgeGraphServiceWorkspaceStatus: + """Tests for KnowledgeGraphService.get_workspace_status.""" + + @pytest.mark.asyncio + async def test_workspace_status_returns_none_when_not_found(self, service, user_id): + """Should return None if KG does not exist.""" + result = await service.get_workspace_status(user_id=user_id, kg_id="missing") + assert result is None + + @pytest.mark.asyncio + async def test_workspace_status_returns_none_when_view_denied( + self, service, kg_repo, user_id + ): + """Should return None if caller lacks VIEW on KG.""" + kg = _make_kg() + kg_repo.seed(kg) + + result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) + assert result is None + + @pytest.mark.asyncio + async def test_workspace_status_includes_mode_readiness_and_session_pointers( + self, service, authz, kg_repo, user_id + ): + """Should project mode/readiness flags and default null session pointers.""" + kg = _make_kg() + kg.set_ontology( + OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ) + ) + kg_repo.seed(kg) + await _grant_kg_view(authz, kg.id.value, user_id) + + result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) + + assert isinstance(result, KnowledgeGraphWorkspaceStatus) + assert result.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP + assert result.readiness.has_minimum_entity_types is True + assert result.readiness.has_minimum_relationship_types is True + assert result.readiness.prepopulated_types_ready is True + assert result.transition_eligible is True + assert result.session_pointers.active_schema_bootstrap_session_id is None + assert result.session_pointers.active_extraction_operations_session_id is None + assert result.session_pointers.most_recent_completed_session_id is None + + @pytest.mark.asyncio + async def test_workspace_status_transition_not_eligible_without_schema_readiness( + self, service, authz, kg_repo, user_id + ): + """Should report transition_eligible false when readiness checks fail.""" + kg = _make_kg() + kg_repo.seed(kg) + await _grant_kg_view(authz, kg.id.value, user_id) + + result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) + + assert result is not None + assert result.readiness.has_minimum_entity_types is False + assert result.readiness.has_minimum_relationship_types is False + assert result.transition_eligible is False + + # ---- list_for_workspace ---- diff --git a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py index 31806882e..36bec0b54 100644 --- a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py +++ b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py @@ -19,7 +19,13 @@ KnowledgeGraphService, ) from management.domain.aggregates import KnowledgeGraph -from management.domain.value_objects import KnowledgeGraphId, WorkspaceMode +from management.domain.value_objects import ( + KnowledgeGraphId, + KnowledgeGraphWorkspaceStatus, + WorkspaceMode, + WorkspaceReadinessStatus, + WorkspaceSessionPointers, +) from management.ports.exceptions import ( DuplicateKnowledgeGraphNameError, KnowledgeGraphNotFoundError, @@ -294,6 +300,64 @@ def test_get_knowledge_graph_returns_404_when_not_found( assert response.status_code == status.HTTP_404_NOT_FOUND +class TestGetKnowledgeGraphWorkspaceStatusRoute: + """Tests for GET /management/knowledge-graphs/{kg_id}/workspace-status.""" + + def test_workspace_status_returns_200_with_projection( + self, + test_client: TestClient, + mock_kg_service: AsyncMock, + sample_knowledge_graph: KnowledgeGraph, + mock_current_user: CurrentUser, + ) -> None: + """Should return mode/readiness/session projection when authorized.""" + mock_kg_service.get_workspace_status.return_value = KnowledgeGraphWorkspaceStatus( + knowledge_graph_id=sample_knowledge_graph.id.value, + workspace_mode=WorkspaceMode.SCHEMA_BOOTSTRAP, + readiness=WorkspaceReadinessStatus( + has_minimum_entity_types=True, + has_minimum_relationship_types=False, + prepopulated_types_ready=True, + ), + transition_eligible=False, + session_pointers=WorkspaceSessionPointers(), + ) + + response = test_client.get( + f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/workspace-status" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["knowledge_graph_id"] == sample_knowledge_graph.id.value + assert payload["workspace_mode"] == WorkspaceMode.SCHEMA_BOOTSTRAP.value + assert payload["readiness"]["has_minimum_entity_types"] is True + assert payload["readiness"]["has_minimum_relationship_types"] is False + assert payload["readiness"]["prepopulated_types_ready"] is True + assert payload["transition_eligible"] is False + assert payload["session_pointers"]["active_schema_bootstrap_session_id"] is None + + mock_kg_service.get_workspace_status.assert_called_once_with( + user_id=mock_current_user.user_id.value, + kg_id=sample_knowledge_graph.id.value, + ) + + def test_workspace_status_returns_404_when_missing_or_unauthorized( + self, + test_client: TestClient, + mock_kg_service: AsyncMock, + sample_knowledge_graph: KnowledgeGraph, + ) -> None: + """Should return 404 when service returns None.""" + mock_kg_service.get_workspace_status.return_value = None + + response = test_client.get( + f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/workspace-status" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + class TestCreateKnowledgeGraphRoute: """Tests for POST /management/workspaces/{workspace_id}/knowledge-graphs endpoint.""" From 9cee053e75570d74840ea0f21ad24d79ff2a0095 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 01:58:47 -0400 Subject: [PATCH 05/80] feat(management): implement actionable bootstrap readiness validation (#682) Enforce workspace readiness checks for minimum entity/relationship type coverage and prepopulated type instance presence, and project blocking reasons so validate/transition workflows can render actionable feedback. Co-authored-by: Cursor --- .../services/knowledge_graph_service.py | 31 ++++++++++++-- src/api/management/domain/value_objects.py | 11 +++++ .../presentation/knowledge_graphs/models.py | 19 +++++++++ .../test_knowledge_graph_service.py | 41 +++++++++++++++++++ .../test_knowledge_graphs_routes.py | 2 + .../management/test_ontology_value_objects.py | 13 ++++++ 6 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/api/management/application/services/knowledge_graph_service.py b/src/api/management/application/services/knowledge_graph_service.py index 2de278904..50746850e 100644 --- a/src/api/management/application/services/knowledge_graph_service.py +++ b/src/api/management/application/services/knowledge_graph_service.py @@ -594,12 +594,35 @@ def _evaluate_workspace_readiness( """Evaluate transition readiness flags for workspace status projection.""" node_type_count = len(kg.ontology.node_types) if kg.ontology else 0 edge_type_count = len(kg.ontology.edge_types) if kg.ontology else 0 + prepopulated_without_instances: tuple[str, ...] = () + if kg.ontology is not None: + prepopulated_without_instances = tuple( + node_type.label + for node_type in kg.ontology.node_types + if node_type.prepopulated and node_type.prepopulated_instance_count <= 0 + ) + + has_min_entities = node_type_count >= 1 + has_min_relationships = edge_type_count >= 1 + prepopulated_ready = len(prepopulated_without_instances) == 0 + + blocking_reasons: list[str] = [] + if not has_min_entities: + blocking_reasons.append("At least one entity type is required") + if not has_min_relationships: + blocking_reasons.append("At least one relationship type is required") + if not prepopulated_ready: + labels = ", ".join(prepopulated_without_instances) + blocking_reasons.append( + f"Prepopulated types require instances before transition: {labels}" + ) - # Prepopulated-instance validation is delivered by later units of work. return WorkspaceReadinessStatus( - has_minimum_entity_types=node_type_count >= 1, - has_minimum_relationship_types=edge_type_count >= 1, - prepopulated_types_ready=True, + has_minimum_entity_types=has_min_entities, + has_minimum_relationship_types=has_min_relationships, + prepopulated_types_ready=prepopulated_ready, + prepopulated_types_without_instances=prepopulated_without_instances, + blocking_reasons=tuple(blocking_reasons), ) async def get_workspace_status( diff --git a/src/api/management/domain/value_objects.py b/src/api/management/domain/value_objects.py index 415d6a40e..185c0159e 100644 --- a/src/api/management/domain/value_objects.py +++ b/src/api/management/domain/value_objects.py @@ -108,6 +108,8 @@ class WorkspaceReadinessStatus: has_minimum_entity_types: bool has_minimum_relationship_types: bool prepopulated_types_ready: bool + prepopulated_types_without_instances: tuple[str, ...] = field(default_factory=tuple) + blocking_reasons: tuple[str, ...] = field(default_factory=tuple) @property def is_ready(self) -> bool: @@ -116,6 +118,7 @@ def is_ready(self) -> bool: self.has_minimum_entity_types and self.has_minimum_relationship_types and self.prepopulated_types_ready + and not self.prepopulated_types_without_instances ) @@ -321,11 +324,15 @@ class NodeTypeDefinition: description: str = "" required_properties: tuple[str, ...] = field(default_factory=tuple) optional_properties: tuple[str, ...] = field(default_factory=tuple) + prepopulated: bool = False + prepopulated_instance_count: int = 0 def __post_init__(self) -> None: """Validate that label is non-empty.""" if not self.label or not self.label.strip(): raise ValueError("NodeTypeDefinition label must not be empty") + if self.prepopulated_instance_count < 0: + raise ValueError("prepopulated_instance_count must be >= 0") def to_dict(self) -> dict[str, Any]: """Serialize to a plain dict suitable for JSON persistence.""" @@ -334,6 +341,8 @@ def to_dict(self) -> dict[str, Any]: "description": self.description, "required_properties": list(self.required_properties), "optional_properties": list(self.optional_properties), + "prepopulated": self.prepopulated, + "prepopulated_instance_count": self.prepopulated_instance_count, } @classmethod @@ -344,6 +353,8 @@ def from_dict(cls, data: dict[str, Any]) -> NodeTypeDefinition: description=data.get("description", ""), required_properties=tuple(data.get("required_properties", [])), optional_properties=tuple(data.get("optional_properties", [])), + prepopulated=bool(data.get("prepopulated", False)), + prepopulated_instance_count=int(data.get("prepopulated_instance_count", 0)), ) diff --git a/src/api/management/presentation/knowledge_graphs/models.py b/src/api/management/presentation/knowledge_graphs/models.py index 7ca060a1c..78ce70a49 100644 --- a/src/api/management/presentation/knowledge_graphs/models.py +++ b/src/api/management/presentation/knowledge_graphs/models.py @@ -110,6 +110,8 @@ class WorkspaceReadinessResponse(BaseModel): has_minimum_entity_types: bool has_minimum_relationship_types: bool prepopulated_types_ready: bool + prepopulated_types_without_instances: list[str] = Field(default_factory=list) + blocking_reasons: list[str] = Field(default_factory=list) @classmethod def from_domain(cls, readiness: WorkspaceReadinessStatus) -> "WorkspaceReadinessResponse": @@ -117,6 +119,10 @@ def from_domain(cls, readiness: WorkspaceReadinessStatus) -> "WorkspaceReadiness has_minimum_entity_types=readiness.has_minimum_entity_types, has_minimum_relationship_types=readiness.has_minimum_relationship_types, prepopulated_types_ready=readiness.prepopulated_types_ready, + prepopulated_types_without_instances=list( + readiness.prepopulated_types_without_instances + ), + blocking_reasons=list(readiness.blocking_reasons), ) @@ -182,6 +188,15 @@ class NodeTypeDefinitionModel(BaseModel): default_factory=list, description="Properties nodes of this type may optionally have", ) + prepopulated: bool = Field( + default=False, + description="Whether this type must have at least one instance before transition", + ) + prepopulated_instance_count: int = Field( + default=0, + ge=0, + description="Current known instance count used for readiness evaluation", + ) def to_domain(self) -> NodeTypeDefinition: """Convert to domain NodeTypeDefinition value object.""" @@ -190,6 +205,8 @@ def to_domain(self) -> NodeTypeDefinition: description=self.description, required_properties=tuple(self.required_properties), optional_properties=tuple(self.optional_properties), + prepopulated=self.prepopulated, + prepopulated_instance_count=self.prepopulated_instance_count, ) @classmethod @@ -200,6 +217,8 @@ def from_domain(cls, nt: NodeTypeDefinition) -> NodeTypeDefinitionModel: description=nt.description, required_properties=list(nt.required_properties), optional_properties=list(nt.optional_properties), + prepopulated=nt.prepopulated, + prepopulated_instance_count=nt.prepopulated_instance_count, ) diff --git a/src/api/tests/unit/management/application/test_knowledge_graph_service.py b/src/api/tests/unit/management/application/test_knowledge_graph_service.py index 8b0264ea0..52d300e90 100644 --- a/src/api/tests/unit/management/application/test_knowledge_graph_service.py +++ b/src/api/tests/unit/management/application/test_knowledge_graph_service.py @@ -463,6 +463,8 @@ async def test_workspace_status_includes_mode_readiness_and_session_pointers( assert result.readiness.has_minimum_entity_types is True assert result.readiness.has_minimum_relationship_types is True assert result.readiness.prepopulated_types_ready is True + assert result.readiness.prepopulated_types_without_instances == () + assert result.readiness.blocking_reasons == () assert result.transition_eligible is True assert result.session_pointers.active_schema_bootstrap_session_id is None assert result.session_pointers.active_extraction_operations_session_id is None @@ -482,6 +484,45 @@ async def test_workspace_status_transition_not_eligible_without_schema_readiness assert result is not None assert result.readiness.has_minimum_entity_types is False assert result.readiness.has_minimum_relationship_types is False + assert "At least one entity type is required" in result.readiness.blocking_reasons + assert ( + "At least one relationship type is required" + in result.readiness.blocking_reasons + ) + assert result.transition_eligible is False + + @pytest.mark.asyncio + async def test_workspace_status_fails_for_prepopulated_type_without_instances( + self, service, authz, kg_repo, user_id + ): + """Should block transition when prepopulated type has zero instances.""" + kg = _make_kg() + kg.set_ontology( + OntologyConfig( + node_types=( + NodeTypeDefinition( + label="Repository", + prepopulated=True, + prepopulated_instance_count=0, + ), + ), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ) + ) + kg_repo.seed(kg) + await _grant_kg_view(authz, kg.id.value, user_id) + + result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) + + assert result is not None + assert result.readiness.prepopulated_types_ready is False + assert result.readiness.prepopulated_types_without_instances == ("Repository",) assert result.transition_eligible is False diff --git a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py index 36bec0b54..32c68da31 100644 --- a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py +++ b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py @@ -334,6 +334,8 @@ def test_workspace_status_returns_200_with_projection( assert payload["readiness"]["has_minimum_entity_types"] is True assert payload["readiness"]["has_minimum_relationship_types"] is False assert payload["readiness"]["prepopulated_types_ready"] is True + assert payload["readiness"]["prepopulated_types_without_instances"] == [] + assert payload["readiness"]["blocking_reasons"] == [] assert payload["transition_eligible"] is False assert payload["session_pointers"]["active_schema_bootstrap_session_id"] is None diff --git a/src/api/tests/unit/management/test_ontology_value_objects.py b/src/api/tests/unit/management/test_ontology_value_objects.py index 645a66ca8..ebf872a6e 100644 --- a/src/api/tests/unit/management/test_ontology_value_objects.py +++ b/src/api/tests/unit/management/test_ontology_value_objects.py @@ -33,6 +33,8 @@ def test_valid_minimal_node_type(self): assert nt.description == "" assert nt.required_properties == () assert nt.optional_properties == () + assert nt.prepopulated is False + assert nt.prepopulated_instance_count == 0 def test_required_properties_default_empty(self): """required_properties defaults to an empty tuple.""" @@ -94,6 +96,17 @@ def test_to_dict_contains_expected_keys(self): assert "description" in d assert "required_properties" in d assert "optional_properties" in d + assert "prepopulated" in d + assert "prepopulated_instance_count" in d + + def test_prepopulated_instance_count_must_be_non_negative(self): + """NodeTypeDefinition should reject negative prepopulated instance counts.""" + with pytest.raises(ValueError, match="prepopulated_instance_count"): + NodeTypeDefinition( + label="Repo", + prepopulated=True, + prepopulated_instance_count=-1, + ) class TestEdgeTypeDefinition: From c305a447bd201eb812bf2154b608278020e3b2ea Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:04:31 -0400 Subject: [PATCH 06/80] feat(management): add workspace validate and transition commands (#683) Expose authorized validate and transition commands for knowledge graph workspaces, persist session pointers, and create an extraction-mode session identifier when moving from bootstrap to extraction operations. Co-authored-by: Cursor --- ...0_add_workspace_session_pointer_columns.py | 46 +++++++++ .../services/knowledge_graph_service.py | 89 ++++++++++++++++- .../domain/aggregates/knowledge_graph.py | 9 +- .../infrastructure/models/knowledge_graph.py | 9 ++ .../knowledge_graph_repository.py | 23 +++++ .../presentation/knowledge_graphs/routes.py | 73 ++++++++++++++ .../test_knowledge_graph_repository.py | 1 + .../test_knowledge_graph_service.py | 97 +++++++++++++++++++ .../test_knowledge_graphs_routes.py | 96 ++++++++++++++++++ .../unit/management/test_knowledge_graph.py | 5 +- 10 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 src/api/infrastructure/migrations/versions/f5b6c7d8e9f0_add_workspace_session_pointer_columns.py diff --git a/src/api/infrastructure/migrations/versions/f5b6c7d8e9f0_add_workspace_session_pointer_columns.py b/src/api/infrastructure/migrations/versions/f5b6c7d8e9f0_add_workspace_session_pointer_columns.py new file mode 100644 index 000000000..57d19eca7 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/f5b6c7d8e9f0_add_workspace_session_pointer_columns.py @@ -0,0 +1,46 @@ +"""add workspace session pointer columns to knowledge_graphs + +Adds nullable session pointer fields used by workspace status projection +and bootstrap-to-extraction transition commands. + +Revision ID: f5b6c7d8e9f0 +Revises: f4a5b6c7d8e9 +Create Date: 2026-05-14 13:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "f5b6c7d8e9f0" +down_revision: Union[str, Sequence[str], None] = "f4a5b6c7d8e9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add workspace session pointer columns.""" + op.add_column( + "knowledge_graphs", + sa.Column("active_schema_bootstrap_session_id", sa.String(length=26), nullable=True), + ) + op.add_column( + "knowledge_graphs", + sa.Column( + "active_extraction_operations_session_id", sa.String(length=26), nullable=True + ), + ) + op.add_column( + "knowledge_graphs", + sa.Column("most_recent_completed_session_id", sa.String(length=26), nullable=True), + ) + + +def downgrade() -> None: + """Drop workspace session pointer columns.""" + op.drop_column("knowledge_graphs", "most_recent_completed_session_id") + op.drop_column("knowledge_graphs", "active_extraction_operations_session_id") + op.drop_column("knowledge_graphs", "active_schema_bootstrap_session_id") diff --git a/src/api/management/application/services/knowledge_graph_service.py b/src/api/management/application/services/knowledge_graph_service.py index 50746850e..f4c7d2d48 100644 --- a/src/api/management/application/services/knowledge_graph_service.py +++ b/src/api/management/application/services/knowledge_graph_service.py @@ -654,5 +654,92 @@ async def get_workspace_status( workspace_mode=kg.workspace_mode, readiness=readiness, transition_eligible=transition_eligible, - session_pointers=WorkspaceSessionPointers(), + session_pointers=WorkspaceSessionPointers( + active_schema_bootstrap_session_id=kg.active_schema_bootstrap_session_id, + active_extraction_operations_session_id=( + kg.active_extraction_operations_session_id + ), + most_recent_completed_session_id=kg.most_recent_completed_session_id, + ), + ) + + async def validate_workspace( + self, + user_id: str, + kg_id: str, + ) -> KnowledgeGraphWorkspaceStatus: + """Validate bootstrap readiness with KG edit authorization.""" + has_edit = await self._check_permission( + user_id=user_id, + resource_type=ResourceType.KNOWLEDGE_GRAPH, + resource_id=kg_id, + permission=Permission.EDIT, + ) + if not has_edit: + self._probe.permission_denied( + user_id=user_id, + resource_id=kg_id, + permission=Permission.EDIT, + ) + raise UnauthorizedError( + f"User {user_id} lacks edit permission on knowledge graph {kg_id}" + ) + + kg = await self._kg_repo.get_by_id(KnowledgeGraphId(value=kg_id)) + if kg is None or kg.tenant_id != self._scope_to_tenant: + raise KnowledgeGraphNotFoundError(f"Knowledge graph {kg_id} not found") + + readiness = self._evaluate_workspace_readiness(kg) + transition_eligible = ( + kg.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP and readiness.is_ready + ) + return KnowledgeGraphWorkspaceStatus( + knowledge_graph_id=kg.id.value, + workspace_mode=kg.workspace_mode, + readiness=readiness, + transition_eligible=transition_eligible, + session_pointers=WorkspaceSessionPointers( + active_schema_bootstrap_session_id=kg.active_schema_bootstrap_session_id, + active_extraction_operations_session_id=( + kg.active_extraction_operations_session_id + ), + most_recent_completed_session_id=kg.most_recent_completed_session_id, + ), + ) + + async def transition_workspace_to_extraction( + self, + user_id: str, + kg_id: str, + ) -> KnowledgeGraphWorkspaceStatus: + """Transition a knowledge graph workspace to extraction_operations mode.""" + _ = await self.validate_workspace(user_id=user_id, kg_id=kg_id) + + kg = await self._kg_repo.get_by_id(KnowledgeGraphId(value=kg_id)) + if kg is None or kg.tenant_id != self._scope_to_tenant: + raise KnowledgeGraphNotFoundError(f"Knowledge graph {kg_id} not found") + + readiness = self._evaluate_workspace_readiness(kg) + if not readiness.is_ready: + joined_reasons = "; ".join(readiness.blocking_reasons) + raise ValueError( + f"Knowledge graph {kg_id} is not ready for transition: {joined_reasons}" + ) + + kg.transition_to_extraction_operations() + await self._kg_repo.save(kg) + await self._session.commit() + + return KnowledgeGraphWorkspaceStatus( + knowledge_graph_id=kg.id.value, + workspace_mode=kg.workspace_mode, + readiness=readiness, + transition_eligible=False, + session_pointers=WorkspaceSessionPointers( + active_schema_bootstrap_session_id=kg.active_schema_bootstrap_session_id, + active_extraction_operations_session_id=( + kg.active_extraction_operations_session_id + ), + most_recent_completed_session_id=kg.most_recent_completed_session_id, + ), ) diff --git a/src/api/management/domain/aggregates/knowledge_graph.py b/src/api/management/domain/aggregates/knowledge_graph.py index 542d6eebd..1067c5c19 100644 --- a/src/api/management/domain/aggregates/knowledge_graph.py +++ b/src/api/management/domain/aggregates/knowledge_graph.py @@ -6,6 +6,8 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING +from ulid import ULID + from management.domain.events import ( KnowledgeGraphCreated, KnowledgeGraphDeleted, @@ -57,6 +59,9 @@ class KnowledgeGraph: updated_at: datetime ontology: OntologyConfig | None = field(default=None) workspace_mode: WorkspaceMode = field(default=WorkspaceMode.SCHEMA_BOOTSTRAP) + active_schema_bootstrap_session_id: str | None = field(default=None) + active_extraction_operations_session_id: str | None = field(default=None) + most_recent_completed_session_id: str | None = field(default=None) _pending_events: list[DomainEvent] = field(default_factory=list, repr=False) _probe: KnowledgeGraphProbe = field( default_factory=DefaultKnowledgeGraphProbe, @@ -237,14 +242,16 @@ def clear_ontology(self) -> None: self.ontology = None self.updated_at = datetime.now(UTC) - def transition_to_extraction_operations(self) -> None: + def transition_to_extraction_operations(self) -> str: """Transition workspace mode from bootstrap to extraction operations.""" if self.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS: raise InvalidWorkspaceModeTransitionError( "Workspace mode is already extraction_operations" ) self.workspace_mode = WorkspaceMode.EXTRACTION_OPERATIONS + self.active_extraction_operations_session_id = str(ULID()) self.updated_at = datetime.now(UTC) + return self.active_extraction_operations_session_id def mark_for_deletion( self, diff --git a/src/api/management/infrastructure/models/knowledge_graph.py b/src/api/management/infrastructure/models/knowledge_graph.py index 51125f721..b22bf5487 100644 --- a/src/api/management/infrastructure/models/knowledge_graph.py +++ b/src/api/management/infrastructure/models/knowledge_graph.py @@ -37,6 +37,15 @@ class KnowledgeGraphModel(Base, TimestampMixin): default=WorkspaceMode.SCHEMA_BOOTSTRAP.value, server_default=WorkspaceMode.SCHEMA_BOOTSTRAP.value, ) + active_schema_bootstrap_session_id: Mapped[str | None] = mapped_column( + String(26), nullable=True + ) + active_extraction_operations_session_id: Mapped[str | None] = mapped_column( + String(26), nullable=True + ) + most_recent_completed_session_id: Mapped[str | None] = mapped_column( + String(26), nullable=True + ) ontology: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None) __table_args__ = ( diff --git a/src/api/management/infrastructure/repositories/knowledge_graph_repository.py b/src/api/management/infrastructure/repositories/knowledge_graph_repository.py index 432ad7699..25de3f8cc 100644 --- a/src/api/management/infrastructure/repositories/knowledge_graph_repository.py +++ b/src/api/management/infrastructure/repositories/knowledge_graph_repository.py @@ -72,6 +72,15 @@ async def save(self, knowledge_graph: KnowledgeGraph) -> None: model.name = knowledge_graph.name model.description = knowledge_graph.description model.workspace_mode = knowledge_graph.workspace_mode.value + model.active_schema_bootstrap_session_id = ( + knowledge_graph.active_schema_bootstrap_session_id + ) + model.active_extraction_operations_session_id = ( + knowledge_graph.active_extraction_operations_session_id + ) + model.most_recent_completed_session_id = ( + knowledge_graph.most_recent_completed_session_id + ) model.updated_at = knowledge_graph.updated_at else: model = KnowledgeGraphModel( @@ -81,6 +90,15 @@ async def save(self, knowledge_graph: KnowledgeGraph) -> None: name=knowledge_graph.name, description=knowledge_graph.description, workspace_mode=knowledge_graph.workspace_mode.value, + active_schema_bootstrap_session_id=( + knowledge_graph.active_schema_bootstrap_session_id + ), + active_extraction_operations_session_id=( + knowledge_graph.active_extraction_operations_session_id + ), + most_recent_completed_session_id=( + knowledge_graph.most_recent_completed_session_id + ), created_at=knowledge_graph.created_at, updated_at=knowledge_graph.updated_at, ) @@ -226,4 +244,9 @@ def _to_domain(self, model: KnowledgeGraphModel) -> KnowledgeGraph: updated_at=model.updated_at, ontology=ontology, workspace_mode=WorkspaceMode(model.workspace_mode), + active_schema_bootstrap_session_id=model.active_schema_bootstrap_session_id, + active_extraction_operations_session_id=( + model.active_extraction_operations_session_id + ), + most_recent_completed_session_id=model.most_recent_completed_session_id, ) diff --git a/src/api/management/presentation/knowledge_graphs/routes.py b/src/api/management/presentation/knowledge_graphs/routes.py index abe645432..666997b98 100644 --- a/src/api/management/presentation/knowledge_graphs/routes.py +++ b/src/api/management/presentation/knowledge_graphs/routes.py @@ -194,6 +194,79 @@ async def get_knowledge_graph_workspace_status( ) +@router.post( + "/knowledge-graphs/{kg_id}/workspace/validate", + response_model=KnowledgeGraphWorkspaceStatusResponse, + summary="Validate bootstrap readiness for workspace transition", +) +async def validate_knowledge_graph_workspace( + kg_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)], +) -> KnowledgeGraphWorkspaceStatusResponse: + """Validate workspace readiness with edit authorization.""" + try: + status_projection = await service.validate_workspace( + user_id=current_user.user_id.value, + kg_id=kg_id, + ) + return KnowledgeGraphWorkspaceStatusResponse.from_domain(status_projection) + except UnauthorizedError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action", + ) + except KnowledgeGraphNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to validate workspace status", + ) + + +@router.post( + "/knowledge-graphs/{kg_id}/workspace/transition-to-extraction", + response_model=KnowledgeGraphWorkspaceStatusResponse, + summary="Transition workspace from bootstrap to extraction operations", +) +async def transition_workspace_to_extraction( + kg_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)], +) -> KnowledgeGraphWorkspaceStatusResponse: + """Transition workspace mode after successful validation.""" + try: + status_projection = await service.transition_workspace_to_extraction( + user_id=current_user.user_id.value, + kg_id=kg_id, + ) + return KnowledgeGraphWorkspaceStatusResponse.from_domain(status_projection) + except UnauthorizedError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action", + ) + except KnowledgeGraphNotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to transition workspace mode", + ) + + @router.post( "/workspaces/{workspace_id}/knowledge-graphs", status_code=status.HTTP_201_CREATED, diff --git a/src/api/tests/integration/management/test_knowledge_graph_repository.py b/src/api/tests/integration/management/test_knowledge_graph_repository.py index d7e6b7c56..262130e33 100644 --- a/src/api/tests/integration/management/test_knowledge_graph_repository.py +++ b/src/api/tests/integration/management/test_knowledge_graph_repository.py @@ -110,6 +110,7 @@ async def test_saves_and_retrieves_workspace_mode( assert retrieved is not None assert retrieved.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS + assert retrieved.active_extraction_operations_session_id is not None class TestKnowledgeGraphUpdate: diff --git a/src/api/tests/unit/management/application/test_knowledge_graph_service.py b/src/api/tests/unit/management/application/test_knowledge_graph_service.py index 52d300e90..1a26b7dae 100644 --- a/src/api/tests/unit/management/application/test_knowledge_graph_service.py +++ b/src/api/tests/unit/management/application/test_knowledge_graph_service.py @@ -526,6 +526,103 @@ async def test_workspace_status_fails_for_prepopulated_type_without_instances( assert result.transition_eligible is False +class TestKnowledgeGraphServiceWorkspaceCommands: + """Tests for validate_workspace and transition_workspace_to_extraction.""" + + @pytest.mark.asyncio + async def test_validate_workspace_requires_edit_permission( + self, service, authz, kg_repo, user_id + ): + kg = _make_kg() + kg_repo.seed(kg) + await _grant_kg_view(authz, kg.id.value, user_id) + + with pytest.raises(UnauthorizedError): + await service.validate_workspace(user_id=user_id, kg_id=kg.id.value) + + @pytest.mark.asyncio + async def test_validate_workspace_returns_projection_when_authorized( + self, service, authz, kg_repo, user_id + ): + kg = _make_kg() + kg_repo.seed(kg) + await _grant_kg_edit(authz, kg.id.value, user_id) + + result = await service.validate_workspace(user_id=user_id, kg_id=kg.id.value) + + assert result.knowledge_graph_id == kg.id.value + assert result.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP + + @pytest.mark.asyncio + async def test_transition_workspace_requires_edit_permission( + self, service, authz, kg_repo, user_id + ): + kg = _make_kg() + kg.set_ontology( + OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ) + ) + kg_repo.seed(kg) + await _grant_kg_view(authz, kg.id.value, user_id) + + with pytest.raises(UnauthorizedError): + await service.transition_workspace_to_extraction( + user_id=user_id, + kg_id=kg.id.value, + ) + + @pytest.mark.asyncio + async def test_transition_workspace_changes_mode_and_creates_session_pointer( + self, service, authz, kg_repo, user_id + ): + kg = _make_kg() + kg.set_ontology( + OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ) + ) + kg_repo.seed(kg) + await _grant_kg_edit(authz, kg.id.value, user_id) + + result = await service.transition_workspace_to_extraction( + user_id=user_id, + kg_id=kg.id.value, + ) + + assert result.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS + assert result.transition_eligible is False + assert result.session_pointers.active_extraction_operations_session_id is not None + + @pytest.mark.asyncio + async def test_transition_workspace_rejects_when_not_ready( + self, service, authz, kg_repo, user_id + ): + kg = _make_kg() + kg_repo.seed(kg) + await _grant_kg_edit(authz, kg.id.value, user_id) + + with pytest.raises(ValueError, match="not ready for transition"): + await service.transition_workspace_to_extraction( + user_id=user_id, + kg_id=kg.id.value, + ) + + # ---- list_for_workspace ---- diff --git a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py index 32c68da31..e7ab13d48 100644 --- a/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py +++ b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py @@ -360,6 +360,102 @@ def test_workspace_status_returns_404_when_missing_or_unauthorized( assert response.status_code == status.HTTP_404_NOT_FOUND +class TestWorkspaceCommandsRoutes: + """Tests for workspace validate/transition command endpoints.""" + + def _status_projection(self, kg_id: str) -> KnowledgeGraphWorkspaceStatus: + return KnowledgeGraphWorkspaceStatus( + knowledge_graph_id=kg_id, + workspace_mode=WorkspaceMode.SCHEMA_BOOTSTRAP, + readiness=WorkspaceReadinessStatus( + has_minimum_entity_types=True, + has_minimum_relationship_types=True, + prepopulated_types_ready=True, + ), + transition_eligible=True, + session_pointers=WorkspaceSessionPointers(), + ) + + def test_validate_workspace_returns_200( + self, + test_client: TestClient, + mock_kg_service: AsyncMock, + sample_knowledge_graph: KnowledgeGraph, + ) -> None: + mock_kg_service.validate_workspace.return_value = self._status_projection( + sample_knowledge_graph.id.value + ) + + response = test_client.post( + f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/workspace/validate" + ) + + assert response.status_code == status.HTTP_200_OK + + def test_validate_workspace_returns_403_when_unauthorized( + self, + test_client: TestClient, + mock_kg_service: AsyncMock, + sample_knowledge_graph: KnowledgeGraph, + ) -> None: + mock_kg_service.validate_workspace.side_effect = UnauthorizedError("forbidden") + + response = test_client.post( + f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/workspace/validate" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_transition_workspace_returns_200( + self, + test_client: TestClient, + mock_kg_service: AsyncMock, + sample_knowledge_graph: KnowledgeGraph, + ) -> None: + transitioned = KnowledgeGraphWorkspaceStatus( + knowledge_graph_id=sample_knowledge_graph.id.value, + workspace_mode=WorkspaceMode.EXTRACTION_OPERATIONS, + readiness=WorkspaceReadinessStatus( + has_minimum_entity_types=True, + has_minimum_relationship_types=True, + prepopulated_types_ready=True, + ), + transition_eligible=False, + session_pointers=WorkspaceSessionPointers( + active_extraction_operations_session_id="01JPQRST1234567890ABCDEFSE" + ), + ) + mock_kg_service.transition_workspace_to_extraction.return_value = transitioned + + response = test_client.post( + f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/workspace/transition-to-extraction" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["workspace_mode"] == WorkspaceMode.EXTRACTION_OPERATIONS.value + assert ( + payload["session_pointers"]["active_extraction_operations_session_id"] + == "01JPQRST1234567890ABCDEFSE" + ) + + def test_transition_workspace_returns_409_when_not_ready( + self, + test_client: TestClient, + mock_kg_service: AsyncMock, + sample_knowledge_graph: KnowledgeGraph, + ) -> None: + mock_kg_service.transition_workspace_to_extraction.side_effect = ValueError( + "not ready" + ) + + response = test_client.post( + f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/workspace/transition-to-extraction" + ) + + assert response.status_code == status.HTTP_409_CONFLICT + + class TestCreateKnowledgeGraphRoute: """Tests for POST /management/workspaces/{workspace_id}/knowledge-graphs endpoint.""" diff --git a/src/api/tests/unit/management/test_knowledge_graph.py b/src/api/tests/unit/management/test_knowledge_graph.py index c30d5e0b0..804c76970 100644 --- a/src/api/tests/unit/management/test_knowledge_graph.py +++ b/src/api/tests/unit/management/test_knowledge_graph.py @@ -240,9 +240,12 @@ def test_transition_to_extraction_operations(self): """Transition should move mode to extraction_operations.""" kg = self._create_kg() - kg.transition_to_extraction_operations() + session_id = kg.transition_to_extraction_operations() assert kg.workspace_mode == WorkspaceMode.EXTRACTION_OPERATIONS + assert kg.active_extraction_operations_session_id == session_id + assert isinstance(session_id, str) + assert len(session_id) == 26 def test_transition_is_irreversible(self): """Transitioning after extraction_operations should fail.""" From 584c3effe7500a7cd0174313d07ce7363e7feb88 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:31:05 -0400 Subject: [PATCH 07/80] feat(management): persist mutation log run metadata on sync runs (#684) Add durable run-level mutation metadata storage and lifecycle persistence for session/scope identity, timestamps, token-cost totals, and operation-count summaries linked to each sync run. Co-authored-by: Cursor --- ..._mutation_log_run_metadata_to_sync_runs.py | 35 ++++++++ .../management/domain/entities/__init__.py | 7 +- .../domain/entities/data_source_sync_run.py | 60 +++++++++++++ .../models/data_source_sync_run.py | 4 + .../data_source_sync_run_repository.py | 17 +++- .../infrastructure/sync_lifecycle_handler.py | 46 ++++++++++ .../test_data_source_sync_run_repository.py | 66 +++++++++++++- .../test_sync_lifecycle_handler.py | 89 +++++++++++++++++++ 8 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 src/api/infrastructure/migrations/versions/f6c7d8e9f0a1_add_mutation_log_run_metadata_to_sync_runs.py diff --git a/src/api/infrastructure/migrations/versions/f6c7d8e9f0a1_add_mutation_log_run_metadata_to_sync_runs.py b/src/api/infrastructure/migrations/versions/f6c7d8e9f0a1_add_mutation_log_run_metadata_to_sync_runs.py new file mode 100644 index 000000000..c3cd68944 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/f6c7d8e9f0a1_add_mutation_log_run_metadata_to_sync_runs.py @@ -0,0 +1,35 @@ +"""add mutation_log_run metadata column to data_source_sync_runs + +Stores run-level mutation log metadata used by extraction/graph lifecycle +tracking (session, actor, timestamps, token/cost totals, operation counts). + +Revision ID: f6c7d8e9f0a1 +Revises: f5b6c7d8e9f0 +Create Date: 2026-05-14 14:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "f6c7d8e9f0a1" +down_revision: Union[str, Sequence[str], None] = "f5b6c7d8e9f0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add nullable JSONB mutation log run metadata column.""" + op.add_column( + "data_source_sync_runs", + sa.Column("mutation_log_run", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + """Drop mutation log run metadata column.""" + op.drop_column("data_source_sync_runs", "mutation_log_run") diff --git a/src/api/management/domain/entities/__init__.py b/src/api/management/domain/entities/__init__.py index c665e7309..81e8ce208 100644 --- a/src/api/management/domain/entities/__init__.py +++ b/src/api/management/domain/entities/__init__.py @@ -4,6 +4,9 @@ They don't emit domain events independently. """ -from management.domain.entities.data_source_sync_run import DataSourceSyncRun +from management.domain.entities.data_source_sync_run import ( + DataSourceSyncRun, + MutationLogRunMetadata, +) -__all__ = ["DataSourceSyncRun"] +__all__ = ["DataSourceSyncRun", "MutationLogRunMetadata"] diff --git a/src/api/management/domain/entities/data_source_sync_run.py b/src/api/management/domain/entities/data_source_sync_run.py index 6bb9ca903..9bf466518 100644 --- a/src/api/management/domain/entities/data_source_sync_run.py +++ b/src/api/management/domain/entities/data_source_sync_run.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime +from typing import Any # Valid sync run status values representing the lifecycle state machine. TERMINAL_STATUSES = frozenset({"completed", "failed"}) @@ -12,6 +13,64 @@ ) +@dataclass +class MutationLogRunMetadata: + """Run-level metadata captured for a produced/applied mutation log.""" + + mutation_log_id: str + knowledge_graph_id: str + session_id: str | None + actor_id: str | None + started_at: datetime + completed_at: datetime | None = None + token_usage_total: int | None = None + cost_total_usd: float | None = None + operation_counts: dict[str, int] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "mutation_log_id": self.mutation_log_id, + "knowledge_graph_id": self.knowledge_graph_id, + "session_id": self.session_id, + "actor_id": self.actor_id, + "started_at": self.started_at.isoformat(), + "completed_at": ( + self.completed_at.isoformat() if self.completed_at is not None else None + ), + "token_usage_total": self.token_usage_total, + "cost_total_usd": self.cost_total_usd, + "operation_counts": self.operation_counts, + } + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> "MutationLogRunMetadata": + return cls( + mutation_log_id=str(raw["mutation_log_id"]), + knowledge_graph_id=str(raw["knowledge_graph_id"]), + session_id=raw.get("session_id"), + actor_id=raw.get("actor_id"), + started_at=datetime.fromisoformat(str(raw["started_at"])), + completed_at=( + datetime.fromisoformat(str(raw["completed_at"])) + if raw.get("completed_at") + else None + ), + token_usage_total=( + int(raw["token_usage_total"]) + if raw.get("token_usage_total") is not None + else None + ), + cost_total_usd=( + float(raw["cost_total_usd"]) + if raw.get("cost_total_usd") is not None + else None + ), + operation_counts={ + str(k): int(v) for k, v in (raw.get("operation_counts") or {}).items() + }, + ) + + @dataclass class DataSourceSyncRun: """Entity tracking the execution of a data source sync. @@ -41,6 +100,7 @@ class DataSourceSyncRun: error: str | None created_at: datetime logs: list[str] = field(default_factory=list) + mutation_log_run: MutationLogRunMetadata | None = None def is_terminal(self) -> bool: """Return True if the sync run is in a terminal state. diff --git a/src/api/management/infrastructure/models/data_source_sync_run.py b/src/api/management/infrastructure/models/data_source_sync_run.py index 4e92dee98..d1401fe96 100644 --- a/src/api/management/infrastructure/models/data_source_sync_run.py +++ b/src/api/management/infrastructure/models/data_source_sync_run.py @@ -15,6 +15,7 @@ String, Text, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from infrastructure.database.models import Base, _utc_now @@ -69,6 +70,9 @@ class DataSourceSyncRunModel(Base): default=list, server_default="{}", ) + mutation_log_run: Mapped[dict | None] = mapped_column( + JSONB, nullable=True, default=None + ) __table_args__ = ( Index("idx_sync_runs_data_source_id", "data_source_id"), diff --git a/src/api/management/infrastructure/repositories/data_source_sync_run_repository.py b/src/api/management/infrastructure/repositories/data_source_sync_run_repository.py index 57997f868..aa234411f 100644 --- a/src/api/management/infrastructure/repositories/data_source_sync_run_repository.py +++ b/src/api/management/infrastructure/repositories/data_source_sync_run_repository.py @@ -11,7 +11,7 @@ from sqlalchemy import desc from sqlalchemy.ext.asyncio import AsyncSession -from management.domain.entities import DataSourceSyncRun +from management.domain.entities import DataSourceSyncRun, MutationLogRunMetadata from management.infrastructure.models import DataSourceSyncRunModel from management.infrastructure.observability import ( DefaultSyncRunRepositoryProbe, @@ -51,6 +51,11 @@ async def save(self, sync_run: DataSourceSyncRun) -> None: model.completed_at = sync_run.completed_at model.error = sync_run.error model.logs = sync_run.logs + model.mutation_log_run = ( + sync_run.mutation_log_run.to_dict() + if sync_run.mutation_log_run is not None + else None + ) else: model = DataSourceSyncRunModel( id=sync_run.id, @@ -61,6 +66,11 @@ async def save(self, sync_run: DataSourceSyncRun) -> None: error=sync_run.error, created_at=sync_run.created_at, logs=sync_run.logs, + mutation_log_run=( + sync_run.mutation_log_run.to_dict() + if sync_run.mutation_log_run is not None + else None + ), ) self._session.add(model) @@ -122,4 +132,9 @@ def _to_domain(self, model: DataSourceSyncRunModel) -> DataSourceSyncRun: error=model.error, created_at=model.created_at, logs=model.logs if model.logs is not None else [], + mutation_log_run=( + MutationLogRunMetadata.from_dict(model.mutation_log_run) + if model.mutation_log_run is not None + else None + ), ) diff --git a/src/api/management/infrastructure/sync_lifecycle_handler.py b/src/api/management/infrastructure/sync_lifecycle_handler.py index 54bcf62c7..5817f6cbb 100644 --- a/src/api/management/infrastructure/sync_lifecycle_handler.py +++ b/src/api/management/infrastructure/sync_lifecycle_handler.py @@ -21,6 +21,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Any +from management.domain.entities import MutationLogRunMetadata from management.domain.value_objects import DataSourceId if TYPE_CHECKING: @@ -122,6 +123,21 @@ async def handle( sync_run.status = "completed" sync_run.completed_at = now sync_run.logs.append(f"[{now.isoformat()}] Sync completed") + if sync_run.mutation_log_run is not None: + sync_run.mutation_log_run.completed_at = now + if payload.get("token_usage_total") is not None: + sync_run.mutation_log_run.token_usage_total = int( + payload["token_usage_total"] + ) + if payload.get("cost_total_usd") is not None: + sync_run.mutation_log_run.cost_total_usd = float( + payload["cost_total_usd"] + ) + if payload.get("operation_counts") is not None: + sync_run.mutation_log_run.operation_counts = { + str(k): int(v) + for k, v in dict(payload["operation_counts"]).items() + } await self._update_data_source_last_sync_at( data_source_id=sync_run.data_source_id, now=now, @@ -135,6 +151,36 @@ async def handle( sync_run.logs.append( f"[{now.isoformat()}] {event_type}: status → {new_status}" ) + if event_type == "MutationLogProduced": + sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id=str(payload["mutation_log_id"]), + knowledge_graph_id=str(payload["knowledge_graph_id"]), + session_id=( + str(payload["session_id"]) + if payload.get("session_id") is not None + else None + ), + actor_id=( + str(payload["actor_id"]) + if payload.get("actor_id") is not None + else None + ), + started_at=now, + token_usage_total=( + int(payload["token_usage_total"]) + if payload.get("token_usage_total") is not None + else None + ), + cost_total_usd=( + float(payload["cost_total_usd"]) + if payload.get("cost_total_usd") is not None + else None + ), + operation_counts={ + str(k): int(v) + for k, v in dict(payload.get("operation_counts") or {}).items() + }, + ) await self._sync_run_repo.save(sync_run) await self._session.commit() diff --git a/src/api/tests/integration/management/test_data_source_sync_run_repository.py b/src/api/tests/integration/management/test_data_source_sync_run_repository.py index 675bf30dc..c441b3b7b 100644 --- a/src/api/tests/integration/management/test_data_source_sync_run_repository.py +++ b/src/api/tests/integration/management/test_data_source_sync_run_repository.py @@ -10,7 +10,7 @@ from sqlalchemy import text from management.domain.aggregates import DataSource, KnowledgeGraph -from management.domain.entities import DataSourceSyncRun +from management.domain.entities import DataSourceSyncRun, MutationLogRunMetadata from management.infrastructure.repositories.data_source_sync_run_repository import ( DataSourceSyncRunRepository, ) @@ -85,6 +85,70 @@ async def test_saves_and_retrieves_sync_run( assert retrieved.error is None assert retrieved.created_at is not None + @pytest.mark.asyncio + async def test_saves_and_retrieves_mutation_log_run_metadata( + self, + data_source_sync_run_repository: DataSourceSyncRunRepository, + data_source_repository: DataSourceRepository, + knowledge_graph_repository: KnowledgeGraphRepository, + async_session, + test_tenant: str, + test_workspace: str, + clean_management_data, + ): + """Should persist mutation log run metadata JSONB for sync runs.""" + kg = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Test KG", + description="For sync run tests", + ) + async with async_session.begin(): + await knowledge_graph_repository.save(kg) + + ds = DataSource.create( + knowledge_graph_id=kg.id.value, + tenant_id=test_tenant, + name="My GitHub Source", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"repo": "org/repo", "branch": "main"}, + ) + async with async_session.begin(): + await data_source_repository.save(ds) + + now = datetime.now(UTC) + sync_run = DataSourceSyncRun( + id=str(ULID()), + data_source_id=ds.id.value, + status="applying", + started_at=now, + completed_at=None, + error=None, + created_at=now, + mutation_log_run=MutationLogRunMetadata( + mutation_log_id="log-001", + knowledge_graph_id=kg.id.value, + session_id="sess-001", + actor_id="user-001", + started_at=now, + token_usage_total=1234, + cost_total_usd=1.5, + operation_counts={"create_node": 2}, + ), + ) + + async with async_session.begin(): + await data_source_sync_run_repository.save(sync_run) + + retrieved = await data_source_sync_run_repository.get_by_id(sync_run.id) + + assert retrieved is not None + assert retrieved.mutation_log_run is not None + assert retrieved.mutation_log_run.mutation_log_id == "log-001" + assert retrieved.mutation_log_run.session_id == "sess-001" + assert retrieved.mutation_log_run.token_usage_total == 1234 + assert retrieved.mutation_log_run.operation_counts["create_node"] == 2 + @pytest.mark.asyncio async def test_saves_completed_sync_run( self, diff --git a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py index 974f60b26..edc049ecc 100644 --- a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py +++ b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py @@ -190,6 +190,39 @@ async def test_mutation_log_produced_sets_applying( saved_run: DataSourceSyncRun = mock_sync_run_repo.save.call_args[0][0] assert saved_run.status == "applying" + async def test_mutation_log_produced_stores_run_metadata( + self, + handler: SyncLifecycleHandler, + mock_sync_run_repo: AsyncMock, + ): + """MutationLogProduced should persist run-level mutation metadata.""" + run = _make_sync_run(status="ai_extracting") + mock_sync_run_repo.get_by_id.return_value = run + + await handler.handle( + "MutationLogProduced", + _payload( + sync_run_id=run.id, + knowledge_graph_id="kg-001", + mutation_log_id="log-001", + session_id="sess-001", + actor_id="user-001", + token_usage_total=1234, + cost_total_usd=1.25, + operation_counts={"create_node": 2, "update_edge": 1}, + ), + ) + + saved_run: DataSourceSyncRun = mock_sync_run_repo.save.call_args[0][0] + assert saved_run.mutation_log_run is not None + assert saved_run.mutation_log_run.mutation_log_id == "log-001" + assert saved_run.mutation_log_run.knowledge_graph_id == "kg-001" + assert saved_run.mutation_log_run.session_id == "sess-001" + assert saved_run.mutation_log_run.actor_id == "user-001" + assert saved_run.mutation_log_run.token_usage_total == 1234 + assert saved_run.mutation_log_run.cost_total_usd == 1.25 + assert saved_run.mutation_log_run.operation_counts["create_node"] == 2 + @pytest.mark.asyncio class TestExtractionFailedTransition: @@ -260,6 +293,62 @@ async def test_mutations_applied_sets_completed( assert saved_run.status == "completed" assert saved_run.completed_at is not None + async def test_mutations_applied_finalizes_mutation_log_metadata( + self, + handler: SyncLifecycleHandler, + mock_sync_run_repo: AsyncMock, + mock_ds_repo: AsyncMock, + ): + """MutationsApplied should finalize mutation run metrics and completed_at.""" + from management.domain.aggregates import DataSource + from management.domain.entities import MutationLogRunMetadata + from management.domain.value_objects import DataSourceId, Schedule, ScheduleType + from shared_kernel.datasource_types import DataSourceAdapterType + + run = _make_sync_run(status="applying") + run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="log-001", + knowledge_graph_id="kg-001", + session_id="sess-001", + actor_id="user-001", + started_at=datetime.now(UTC), + ) + mock_sync_run_repo.get_by_id.return_value = run + + now = datetime.now(UTC) + ds = DataSource( + id=DataSourceId(value="ds-001"), + knowledge_graph_id="kg-001", + tenant_id="tenant-001", + name="My DS", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={}, + credentials_path=None, + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + ) + mock_ds_repo.get_by_id.return_value = ds + + await handler.handle( + "MutationsApplied", + _payload( + sync_run_id=run.id, + knowledge_graph_id="kg-001", + token_usage_total=4321, + cost_total_usd=2.5, + operation_counts={"create_node": 9}, + ), + ) + + saved_run: DataSourceSyncRun = mock_sync_run_repo.save.call_args[0][0] + assert saved_run.mutation_log_run is not None + assert saved_run.mutation_log_run.completed_at is not None + assert saved_run.mutation_log_run.token_usage_total == 4321 + assert saved_run.mutation_log_run.cost_total_usd == 2.5 + assert saved_run.mutation_log_run.operation_counts == {"create_node": 9} + async def test_mutations_applied_updates_data_source_last_sync_at( self, handler: SyncLifecycleHandler, From 60ce34f002ab9bd6e16db457a4c908e90bfb79b5 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:32:47 -0400 Subject: [PATCH 08/80] feat(graph): wire mutation apply metrics into lifecycle events (#685) Emit operation-class counts and token/cost totals from mutation-log application results into MutationsApplied payloads so downstream sync lifecycle persistence can finalize run-level metadata. Co-authored-by: Cursor --- src/api/graph/infrastructure/event_handler.py | 12 +++++++++--- src/api/graph/ports/mutation_log.py | 15 +++++++++++++-- src/api/main.py | 3 ++- .../test_graph_mutation_event_handler.py | 16 ++++++++++++++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/api/graph/infrastructure/event_handler.py b/src/api/graph/infrastructure/event_handler.py index df4bfebe8..b22b9fe5e 100644 --- a/src/api/graph/infrastructure/event_handler.py +++ b/src/api/graph/infrastructure/event_handler.py @@ -14,7 +14,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Any -from graph.ports.mutation_log import IMutationLogApplier +from graph.ports.mutation_log import IMutationLogApplier, MutationLogApplyResult if TYPE_CHECKING: from shared_kernel.outbox.ports import IOutboxRepository @@ -77,17 +77,20 @@ async def handle( now = datetime.now(UTC) try: - success = await self._mutation_log_applier.apply_mutation_log( + apply_result = await self._mutation_log_applier.apply_mutation_log( mutation_log_id ) - if success: + if apply_result.success: await self._outbox.append( event_type="MutationsApplied", payload={ "sync_run_id": sync_run_id, "data_source_id": data_source_id, "knowledge_graph_id": knowledge_graph_id, + "operation_counts": apply_result.operation_counts, + "token_usage_total": apply_result.token_usage_total, + "cost_total_usd": apply_result.cost_total_usd, "occurred_at": now.isoformat(), }, occurred_at=now, @@ -101,6 +104,9 @@ async def handle( "sync_run_id": sync_run_id, "data_source_id": data_source_id, "error": "Mutation application returned failure", + "operation_counts": apply_result.operation_counts, + "token_usage_total": apply_result.token_usage_total, + "cost_total_usd": apply_result.cost_total_usd, "occurred_at": now.isoformat(), }, occurred_at=now, diff --git a/src/api/graph/ports/mutation_log.py b/src/api/graph/ports/mutation_log.py index dcf6037c6..14e06c85e 100644 --- a/src/api/graph/ports/mutation_log.py +++ b/src/api/graph/ports/mutation_log.py @@ -2,9 +2,20 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Protocol +@dataclass(frozen=True) +class MutationLogApplyResult: + """Result metadata produced when applying a mutation log.""" + + success: bool + operation_counts: dict[str, int] = field(default_factory=dict) + token_usage_total: int | None = None + cost_total_usd: float | None = None + + class IMutationLogApplier(Protocol): """Protocol for applying a MutationLog to the graph database. @@ -15,7 +26,7 @@ class IMutationLogApplier(Protocol): infrastructure (AGE connection pools, bulk loading strategies, etc.). """ - async def apply_mutation_log(self, mutation_log_id: str) -> bool: + async def apply_mutation_log(self, mutation_log_id: str) -> MutationLogApplyResult: """Apply all mutations from a MutationLog to the graph database. Args: @@ -24,7 +35,7 @@ async def apply_mutation_log(self, mutation_log_id: str) -> bool: log content from storage (filesystem, object store, etc.). Returns: - True if all mutations were applied successfully. + MutationLogApplyResult with success flag and finalized run metrics. Raises: Exception: Any exception signals a failure; callers should diff --git a/src/api/main.py b/src/api/main.py index 074b0c232..ffeeff0a4 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -48,6 +48,7 @@ ) from infrastructure.mcp_dependencies import dispose_mcp_auth_engine from query.presentation.mcp import mcp_http_app_proxy, query_mcp_app +from graph.ports.mutation_log import MutationLogApplyResult # Default work directory for JobPackage ZIP archives _JOB_PACKAGE_WORK_DIR = Path("/tmp/kartograph/job_packages") # noqa: S108 @@ -238,7 +239,7 @@ class _StubMutationLogApplier: result in MutationApplicationFailed being emitted. """ - async def apply_mutation_log(self, mutation_log_id: str) -> bool: + async def apply_mutation_log(self, mutation_log_id: str) -> MutationLogApplyResult: raise NotImplementedError( "Graph mutation application via outbox is not yet fully implemented. " "Register a real IMutationLogApplier to enable graph writes from the outbox." diff --git a/src/api/tests/unit/graph/infrastructure/test_graph_mutation_event_handler.py b/src/api/tests/unit/graph/infrastructure/test_graph_mutation_event_handler.py index 2a2feda5c..31f5d190b 100644 --- a/src/api/tests/unit/graph/infrastructure/test_graph_mutation_event_handler.py +++ b/src/api/tests/unit/graph/infrastructure/test_graph_mutation_event_handler.py @@ -18,6 +18,7 @@ import pytest from graph.infrastructure.event_handler import GraphMutationEventHandler +from graph.ports.mutation_log import MutationLogApplyResult class _FakeOutboxRepository: @@ -59,11 +60,16 @@ def __init__(self, fail: bool = False, error: str = "DB write error") -> None: self._error = error self.calls: list[str] = [] - async def apply_mutation_log(self, mutation_log_id: str) -> bool: + async def apply_mutation_log(self, mutation_log_id: str) -> MutationLogApplyResult: self.calls.append(mutation_log_id) if self._fail: raise RuntimeError(self._error) - return True + return MutationLogApplyResult( + success=True, + operation_counts={"create_node": 2, "update_edge": 1}, + token_usage_total=321, + cost_total_usd=0.42, + ) @pytest.fixture @@ -150,6 +156,12 @@ async def test_emits_mutations_applied_on_success( assert event["payload"]["sync_run_id"] == "run-001" assert event["payload"]["data_source_id"] == "ds-001" assert event["payload"]["knowledge_graph_id"] == "kg-001" + assert event["payload"]["operation_counts"] == { + "create_node": 2, + "update_edge": 1, + } + assert event["payload"]["token_usage_total"] == 321 + assert event["payload"]["cost_total_usd"] == 0.42 async def test_mutations_applied_aggregate_type( self, From d50c238cf8912b9d05cda0340a2fe6bd6e4bd73f Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:35:20 -0400 Subject: [PATCH 09/80] test(extraction): add bounded-context architecture guardrails scaffold (#686) Scaffold extraction application/presentation package structure and add pytest-archon rules enforcing DDD layer boundaries plus cross-context isolation so subsequent extraction features stay architecturally clean. Co-authored-by: Cursor --- src/api/extraction/application/__init__.py | 6 + src/api/extraction/application/services.py | 6 + src/api/extraction/infrastructure/__init__.py | 6 + src/api/extraction/ports/__init__.py | 6 + src/api/extraction/presentation/__init__.py | 6 + .../unit/extraction/test_architecture.py | 201 ++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 src/api/extraction/application/__init__.py create mode 100644 src/api/extraction/application/services.py create mode 100644 src/api/extraction/presentation/__init__.py create mode 100644 src/api/tests/unit/extraction/test_architecture.py diff --git a/src/api/extraction/application/__init__.py b/src/api/extraction/application/__init__.py new file mode 100644 index 000000000..13e58a0b4 --- /dev/null +++ b/src/api/extraction/application/__init__.py @@ -0,0 +1,6 @@ +"""Extraction application layer. + +Application services orchestrate extraction workflows using domain logic +and port contracts. They do not directly depend on infrastructure. +""" + diff --git a/src/api/extraction/application/services.py b/src/api/extraction/application/services.py new file mode 100644 index 000000000..12cf7a5aa --- /dev/null +++ b/src/api/extraction/application/services.py @@ -0,0 +1,6 @@ +"""Application service contracts for Extraction workflows. + +Concrete implementations will orchestrate long-running extraction sessions, +agent execution, and mutation-log production. +""" + diff --git a/src/api/extraction/infrastructure/__init__.py b/src/api/extraction/infrastructure/__init__.py index e69de29bb..f7a85405f 100644 --- a/src/api/extraction/infrastructure/__init__.py +++ b/src/api/extraction/infrastructure/__init__.py @@ -0,0 +1,6 @@ +"""Extraction infrastructure adapters and event handlers.""" + +from extraction.infrastructure.event_handler import ExtractionEventHandler + +__all__ = ["ExtractionEventHandler"] + diff --git a/src/api/extraction/ports/__init__.py b/src/api/extraction/ports/__init__.py index e69de29bb..817081a56 100644 --- a/src/api/extraction/ports/__init__.py +++ b/src/api/extraction/ports/__init__.py @@ -0,0 +1,6 @@ +"""Extraction port contracts.""" + +from extraction.ports.services import IExtractionService + +__all__ = ["IExtractionService"] + diff --git a/src/api/extraction/presentation/__init__.py b/src/api/extraction/presentation/__init__.py new file mode 100644 index 000000000..a87e6bef8 --- /dev/null +++ b/src/api/extraction/presentation/__init__.py @@ -0,0 +1,6 @@ +"""Extraction presentation layer. + +HTTP/MCP routes for extraction session and operation workflows are defined +here as the bounded context expands. +""" + diff --git a/src/api/tests/unit/extraction/test_architecture.py b/src/api/tests/unit/extraction/test_architecture.py new file mode 100644 index 000000000..f6e2fcb2c --- /dev/null +++ b/src/api/tests/unit/extraction/test_architecture.py @@ -0,0 +1,201 @@ +"""Architecture tests for the Extraction bounded context.""" + +import importlib + +import pytest +from pytest_archon import archrule + + +def _subpackage_exists(name: str) -> bool: + """Return True when package exists, False when missing.""" + try: + importlib.import_module(name) + return True + except ModuleNotFoundError as e: + if e.name == name: + return False + raise + + +_has_domain = _subpackage_exists("extraction.domain") +_has_ports = _subpackage_exists("extraction.ports") +_has_application = _subpackage_exists("extraction.application") +_has_infrastructure = _subpackage_exists("extraction.infrastructure") +_has_presentation = _subpackage_exists("extraction.presentation") + +_skip_no_domain = pytest.mark.skipif( + not _has_domain, + reason="extraction.domain subpackage does not exist yet", +) +_skip_no_ports = pytest.mark.skipif( + not _has_ports, + reason="extraction.ports subpackage does not exist yet", +) +_skip_no_application = pytest.mark.skipif( + not _has_application, + reason="extraction.application subpackage does not exist yet", +) +_skip_no_infrastructure = pytest.mark.skipif( + not _has_infrastructure, + reason="extraction.infrastructure subpackage does not exist yet", +) +_skip_no_presentation = pytest.mark.skipif( + not _has_presentation, + reason="extraction.presentation subpackage does not exist yet", +) + + +@_skip_no_domain +class TestExtractionDomainLayerBoundaries: + def test_domain_does_not_import_infrastructure(self): + ( + archrule("extraction_domain_no_infrastructure") + .match("extraction.domain*") + .should_not_import("extraction.infrastructure*") + .check("extraction") + ) + + def test_domain_does_not_import_application(self): + ( + archrule("extraction_domain_no_application") + .match("extraction.domain*") + .should_not_import("extraction.application*") + .check("extraction") + ) + + def test_domain_does_not_import_fastapi(self): + ( + archrule("extraction_domain_no_fastapi") + .match("extraction.domain*") + .should_not_import("fastapi*", "starlette*") + .check("extraction") + ) + + +@_skip_no_ports +class TestExtractionPortsLayerBoundaries: + def test_ports_does_not_import_infrastructure(self): + ( + archrule("extraction_ports_no_infrastructure") + .match("extraction.ports*") + .should_not_import("extraction.infrastructure*") + .check("extraction") + ) + + def test_ports_does_not_import_application(self): + ( + archrule("extraction_ports_no_application") + .match("extraction.ports*") + .should_not_import("extraction.application*") + .check("extraction") + ) + + +@_skip_no_application +class TestExtractionApplicationLayerBoundaries: + def test_application_does_not_import_infrastructure(self): + ( + archrule("extraction_application_no_infrastructure") + .match("extraction.application*") + .should_not_import("extraction.infrastructure*") + .check("extraction") + ) + + def test_application_may_import_domain_and_ports(self): + ( + archrule("extraction_application_may_import_domain_ports") + .match("extraction.application*") + .may_import("extraction.domain*", "extraction.ports*") + .check("extraction") + ) + + +@_skip_no_infrastructure +class TestExtractionInfrastructureLayerBoundaries: + def test_infrastructure_does_not_import_application(self): + ( + archrule("extraction_infrastructure_no_application") + .match("extraction.infrastructure*") + .should_not_import("extraction.application*") + .check("extraction") + ) + + def test_infrastructure_may_import_domain_and_ports(self): + ( + archrule("extraction_infrastructure_may_import_domain_ports") + .match("extraction.infrastructure*") + .may_import("extraction.domain*", "extraction.ports*") + .check("extraction") + ) + + +@_skip_no_presentation +class TestExtractionPresentationLayerBoundaries: + def test_presentation_does_not_import_other_contexts(self): + ( + archrule("extraction_presentation_no_cross_context_imports") + .match("extraction.presentation*") + .should_not_import("graph*", "management*", "ingestion*", "query*") + .check("extraction") + ) + + +class TestExtractionBoundedContextIsolation: + def test_extraction_does_not_import_iam(self): + ( + archrule("extraction_no_iam") + .match("extraction*") + .should_not_import("iam*") + .check("extraction") + ) + + def test_extraction_does_not_import_management(self): + ( + archrule("extraction_no_management") + .match("extraction*") + .should_not_import("management*") + .check("extraction") + ) + + def test_extraction_does_not_import_ingestion(self): + ( + archrule("extraction_no_ingestion") + .match("extraction*") + .should_not_import("ingestion*") + .check("extraction") + ) + + def test_extraction_does_not_import_graph(self): + ( + archrule("extraction_no_graph") + .match("extraction*") + .should_not_import("graph*") + .check("extraction") + ) + + def test_extraction_does_not_import_query(self): + ( + archrule("extraction_no_query") + .match("extraction*") + .should_not_import("query*") + .check("extraction") + ) + + +class TestExtractionAllowedDependencies: + def test_extraction_may_import_shared_kernel(self): + ( + archrule("extraction_may_import_shared_kernel") + .match("extraction*") + .may_import("shared_kernel*") + .check("extraction") + ) + + def test_extraction_may_import_infrastructure(self): + ( + archrule("extraction_may_import_infrastructure") + .match("extraction*") + .may_import("infrastructure*") + .check("extraction") + ) + From 9f91318f5dec68e0455fa17df5f4bf448a970d3c Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:39:00 -0400 Subject: [PATCH 10/80] feat(extraction): add scoped agent session lifecycle service (#687) Implement per-user/per-knowledge-graph/per-mode extraction session lifecycle behaviors with clear-chat reset semantics and archived-session retention backed by repository ports and unit coverage. Co-authored-by: Cursor --- src/api/extraction/application/__init__.py | 4 + .../application/agent_session_service.py | 82 +++++++++ src/api/extraction/domain/__init__.py | 5 + .../extraction/domain/entities/__init__.py | 6 + .../domain/entities/agent_session.py | 34 ++++ src/api/extraction/domain/value_objects.py | 11 ++ src/api/extraction/ports/__init__.py | 3 +- src/api/extraction/ports/repositories.py | 31 ++++ .../application/test_agent_session_service.py | 161 ++++++++++++++++++ 9 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/api/extraction/application/agent_session_service.py create mode 100644 src/api/extraction/domain/entities/__init__.py create mode 100644 src/api/extraction/domain/entities/agent_session.py create mode 100644 src/api/extraction/domain/value_objects.py create mode 100644 src/api/extraction/ports/repositories.py create mode 100644 src/api/tests/unit/extraction/application/test_agent_session_service.py diff --git a/src/api/extraction/application/__init__.py b/src/api/extraction/application/__init__.py index 13e58a0b4..27c34129b 100644 --- a/src/api/extraction/application/__init__.py +++ b/src/api/extraction/application/__init__.py @@ -4,3 +4,7 @@ and port contracts. They do not directly depend on infrastructure. """ +from extraction.application.agent_session_service import ExtractionAgentSessionService + +__all__ = ["ExtractionAgentSessionService"] + diff --git a/src/api/extraction/application/agent_session_service.py b/src/api/extraction/application/agent_session_service.py new file mode 100644 index 000000000..646489e7c --- /dev/null +++ b/src/api/extraction/application/agent_session_service.py @@ -0,0 +1,82 @@ +"""Application service for extraction agent session lifecycle.""" + +from __future__ import annotations + +from ulid import ULID + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode +from extraction.ports.repositories import IExtractionAgentSessionRepository + + +class ExtractionAgentSessionService: + """Orchestrates session create/get/list/archive behaviors by scope.""" + + def __init__(self, repository: IExtractionAgentSessionRepository) -> None: + self._repository = repository + + async def get_or_create_active_session( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession: + existing = await self._repository.find_active_by_scope( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + if existing is not None: + return existing + + session = ExtractionAgentSession( + id=str(ULID()), + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + await self._repository.save(session) + return session + + async def clear_chat( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession: + active = await self._repository.find_active_by_scope( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + if active is not None: + active.archive() + await self._repository.save(active) + + return await self.get_or_create_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + async def list_sessions( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + return await self._repository.list_by_scope( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + async def archive_session(self, session_id: str) -> ExtractionAgentSession | None: + session = await self._repository.get_by_id(session_id) + if session is None: + return None + if session.is_active: + session.archive() + await self._repository.save(session) + return session + diff --git a/src/api/extraction/domain/__init__.py b/src/api/extraction/domain/__init__.py index 119ee9827..0a6dd4f26 100644 --- a/src/api/extraction/domain/__init__.py +++ b/src/api/extraction/domain/__init__.py @@ -1 +1,6 @@ """Extraction domain layer.""" + +from extraction.domain.entities import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode + +__all__ = ["ExtractionAgentSession", "ExtractionSessionMode"] diff --git a/src/api/extraction/domain/entities/__init__.py b/src/api/extraction/domain/entities/__init__.py new file mode 100644 index 000000000..50eafd016 --- /dev/null +++ b/src/api/extraction/domain/entities/__init__.py @@ -0,0 +1,6 @@ +"""Extraction domain entities.""" + +from extraction.domain.entities.agent_session import ExtractionAgentSession + +__all__ = ["ExtractionAgentSession"] + diff --git a/src/api/extraction/domain/entities/agent_session.py b/src/api/extraction/domain/entities/agent_session.py new file mode 100644 index 000000000..50903162e --- /dev/null +++ b/src/api/extraction/domain/entities/agent_session.py @@ -0,0 +1,34 @@ +"""Extraction agent session entity.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from extraction.domain.value_objects import ExtractionSessionMode + + +@dataclass +class ExtractionAgentSession: + """Long-running conversational session scoped to user/KG/mode.""" + + id: str + user_id: str + knowledge_graph_id: str + mode: ExtractionSessionMode + message_history: list[dict[str, Any]] = field(default_factory=list) + runtime_context: dict[str, Any] = field(default_factory=dict) + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + archived_at: datetime | None = None + + @property + def is_active(self) -> bool: + return self.archived_at is None + + def archive(self, *, when: datetime | None = None) -> None: + now = when or datetime.now(UTC) + self.archived_at = now + self.updated_at = now + diff --git a/src/api/extraction/domain/value_objects.py b/src/api/extraction/domain/value_objects.py new file mode 100644 index 000000000..c12cdfd2b --- /dev/null +++ b/src/api/extraction/domain/value_objects.py @@ -0,0 +1,11 @@ +"""Value objects for Extraction session lifecycle.""" + +from enum import StrEnum + + +class ExtractionSessionMode(StrEnum): + """Workspace mode for extraction agent sessions.""" + + SCHEMA_BOOTSTRAP = "schema_bootstrap" + EXTRACTION_OPERATIONS = "extraction_operations" + diff --git a/src/api/extraction/ports/__init__.py b/src/api/extraction/ports/__init__.py index 817081a56..ad1aff4f9 100644 --- a/src/api/extraction/ports/__init__.py +++ b/src/api/extraction/ports/__init__.py @@ -1,6 +1,7 @@ """Extraction port contracts.""" +from extraction.ports.repositories import IExtractionAgentSessionRepository from extraction.ports.services import IExtractionService -__all__ = ["IExtractionService"] +__all__ = ["IExtractionService", "IExtractionAgentSessionRepository"] diff --git a/src/api/extraction/ports/repositories.py b/src/api/extraction/ports/repositories.py new file mode 100644 index 000000000..129a2056e --- /dev/null +++ b/src/api/extraction/ports/repositories.py @@ -0,0 +1,31 @@ +"""Repository ports for Extraction sessions.""" + +from __future__ import annotations + +from typing import Protocol + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode + + +class IExtractionAgentSessionRepository(Protocol): + """Persistence contract for extraction agent sessions.""" + + async def save(self, session: ExtractionAgentSession) -> None: ... + + async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: ... + + async def find_active_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession | None: ... + + async def list_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: ... + diff --git a/src/api/tests/unit/extraction/application/test_agent_session_service.py b/src/api/tests/unit/extraction/application/test_agent_session_service.py new file mode 100644 index 000000000..aade8550f --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_agent_session_service.py @@ -0,0 +1,161 @@ +"""Unit tests for ExtractionAgentSessionService.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime + +import pytest + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode + + +class _InMemoryAgentSessionRepository: + def __init__(self) -> None: + self._by_id: dict[str, ExtractionAgentSession] = {} + + async def save(self, session: ExtractionAgentSession) -> None: + self._by_id[session.id] = replace(session) + + async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: + session = self._by_id.get(session_id) + return replace(session) if session else None + + async def find_active_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession | None: + for session in self._by_id.values(): + if ( + session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and session.mode == mode + and session.archived_at is None + ): + return replace(session) + return None + + async def list_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + sessions = [ + replace(session) + for session in self._by_id.values() + if session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and (mode is None or session.mode == mode) + ] + return sorted(sessions, key=lambda s: s.updated_at, reverse=True) + + +@pytest.mark.asyncio +class TestExtractionAgentSessionService: + async def test_reuses_active_session_for_same_scope(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + + first = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + second = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + + assert first.id == second.id + + async def test_scope_isolated_by_user(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + + first = await service.get_or_create_active_session( + user_id="alice", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + second = await service.get_or_create_active_session( + user_id="bob", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + assert first.id != second.id + + async def test_scope_isolated_by_mode(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + + bootstrap = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + operations = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + assert bootstrap.id != operations.id + + async def test_clear_chat_archives_old_session_and_creates_new_one(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + + old_session = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + old_session.message_history = [{"role": "user", "content": "hello"}] + old_session.runtime_context = {"draft": "x"} + old_session.updated_at = datetime.now(UTC) + await repo.save(old_session) + + new_session = await service.clear_chat( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + archived = await repo.get_by_id(old_session.id) + assert archived is not None + assert archived.archived_at is not None + assert new_session.id != old_session.id + assert new_session.message_history == [] + assert new_session.runtime_context == {} + + async def test_list_sessions_includes_archived_history(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + + first = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + await service.clear_chat( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + sessions = await service.list_sessions( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + assert len(sessions) == 2 + assert any(session.id == first.id and session.archived_at is not None for session in sessions) + From 56b9ba74255264c8bcfc2d5d28cc610df6a2abee Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:41:56 -0400 Subject: [PATCH 11/80] feat(extraction): add skill resolution engine with KG overrides (#688) Resolve mode-specific extraction skill templates from global defaults and apply deterministic knowledge-graph override merges so session prompts are stable, customizable, and repeatable. Co-authored-by: Cursor --- src/api/extraction/application/__init__.py | 5 +- .../application/skill_resolution_service.py | 66 +++++++++++++ src/api/extraction/ports/__init__.py | 11 ++- src/api/extraction/ports/repositories.py | 10 ++ .../test_skill_resolution_service.py | 96 +++++++++++++++++++ 5 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/api/extraction/application/skill_resolution_service.py create mode 100644 src/api/tests/unit/extraction/application/test_skill_resolution_service.py diff --git a/src/api/extraction/application/__init__.py b/src/api/extraction/application/__init__.py index 27c34129b..fd5d9c04c 100644 --- a/src/api/extraction/application/__init__.py +++ b/src/api/extraction/application/__init__.py @@ -5,6 +5,9 @@ """ from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.skill_resolution_service import ( + ExtractionSkillResolutionService, +) -__all__ = ["ExtractionAgentSessionService"] +__all__ = ["ExtractionAgentSessionService", "ExtractionSkillResolutionService"] diff --git a/src/api/extraction/application/skill_resolution_service.py b/src/api/extraction/application/skill_resolution_service.py new file mode 100644 index 000000000..a2d1b7f83 --- /dev/null +++ b/src/api/extraction/application/skill_resolution_service.py @@ -0,0 +1,66 @@ +"""Skill resolution for extraction sessions.""" + +from __future__ import annotations + +from extraction.domain.value_objects import ExtractionSessionMode +from extraction.ports.repositories import IExtractionSkillOverrideRepository + + +_GLOBAL_SKILL_TEMPLATES: dict[ExtractionSessionMode, dict[str, str]] = { + ExtractionSessionMode.SCHEMA_BOOTSTRAP: { + "schema_modeling": ( + "Guide the user to define complete entity and relationship types " + "with clear labels, constraints, and required properties." + ), + "prepopulation_validation": ( + "Prioritize prepopulated type coverage and highlight any missing " + "instances required before extraction-mode transition." + ), + }, + ExtractionSessionMode.EXTRACTION_OPERATIONS: { + "job_setup": ( + "Prioritize extraction job setup, file-targeting strategy, and " + "safe incremental mutation planning." + ), + "minor_edits": ( + "Allow focused direct graph edits while preserving mutation-log " + "auditability and schema consistency." + ), + "schema_edits_secondary": ( + "Keep schema edits available but framed as secondary to " + "extraction and maintenance operations." + ), + }, +} + + +class ExtractionSkillResolutionService: + """Resolve session skills from global templates + KG overrides.""" + + def __init__(self, override_repository: IExtractionSkillOverrideRepository) -> None: + self._override_repository = override_repository + + async def resolve_for_session( + self, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> dict[str, str]: + base_templates = dict(_GLOBAL_SKILL_TEMPLATES[mode]) + overrides = await self._override_repository.get_overrides_for_knowledge_graph( + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + resolved = dict(base_templates) + + # Merge existing keys first, then append new override keys in sorted order + # to ensure deterministic ordering across runs. + for key in sorted(overrides.keys()): + if key in resolved: + resolved[key] = overrides[key] + for key in sorted(overrides.keys()): + if key not in resolved: + resolved[key] = overrides[key] + + return resolved + diff --git a/src/api/extraction/ports/__init__.py b/src/api/extraction/ports/__init__.py index ad1aff4f9..d3e72d0e9 100644 --- a/src/api/extraction/ports/__init__.py +++ b/src/api/extraction/ports/__init__.py @@ -1,7 +1,14 @@ """Extraction port contracts.""" -from extraction.ports.repositories import IExtractionAgentSessionRepository +from extraction.ports.repositories import ( + IExtractionAgentSessionRepository, + IExtractionSkillOverrideRepository, +) from extraction.ports.services import IExtractionService -__all__ = ["IExtractionService", "IExtractionAgentSessionRepository"] +__all__ = [ + "IExtractionService", + "IExtractionAgentSessionRepository", + "IExtractionSkillOverrideRepository", +] diff --git a/src/api/extraction/ports/repositories.py b/src/api/extraction/ports/repositories.py index 129a2056e..c9c9bb597 100644 --- a/src/api/extraction/ports/repositories.py +++ b/src/api/extraction/ports/repositories.py @@ -29,3 +29,13 @@ async def list_by_scope( mode: ExtractionSessionMode | None = None, ) -> list[ExtractionAgentSession]: ... + +class IExtractionSkillOverrideRepository(Protocol): + """Read KG-specific skill override templates.""" + + async def get_overrides_for_knowledge_graph( + self, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> dict[str, str]: ... + diff --git a/src/api/tests/unit/extraction/application/test_skill_resolution_service.py b/src/api/tests/unit/extraction/application/test_skill_resolution_service.py new file mode 100644 index 000000000..90bb67082 --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_skill_resolution_service.py @@ -0,0 +1,96 @@ +"""Unit tests for ExtractionSkillResolutionService.""" + +from __future__ import annotations + +import pytest + +from extraction.application.skill_resolution_service import ( + ExtractionSkillResolutionService, +) +from extraction.domain.value_objects import ExtractionSessionMode + + +class _InMemorySkillOverrideRepository: + def __init__(self, overrides: dict[tuple[str, ExtractionSessionMode], dict[str, str]] | None = None) -> None: + self._overrides = overrides or {} + + async def get_overrides_for_knowledge_graph( + self, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> dict[str, str]: + return dict(self._overrides.get((knowledge_graph_id, mode), {})) + + +@pytest.mark.asyncio +class TestExtractionSkillResolutionService: + async def test_bootstrap_mode_uses_bootstrap_defaults(self): + service = ExtractionSkillResolutionService( + override_repository=_InMemorySkillOverrideRepository() + ) + + resolved = await service.resolve_for_session( + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + + assert "schema_modeling" in resolved + assert "prepopulation_validation" in resolved + + async def test_extraction_mode_uses_extraction_defaults(self): + service = ExtractionSkillResolutionService( + override_repository=_InMemorySkillOverrideRepository() + ) + + resolved = await service.resolve_for_session( + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + assert "job_setup" in resolved + assert "minor_edits" in resolved + + async def test_kg_overrides_replace_matching_template_and_append_new(self): + repo = _InMemorySkillOverrideRepository( + overrides={ + ( + "kg-1", + ExtractionSessionMode.EXTRACTION_OPERATIONS, + ): { + "job_setup": "KG-specific job setup instructions", + "custom_review": "Custom review flow", + } + } + ) + service = ExtractionSkillResolutionService(override_repository=repo) + + resolved = await service.resolve_for_session( + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + assert resolved["job_setup"] == "KG-specific job setup instructions" + assert resolved["custom_review"] == "Custom review flow" + + async def test_override_merge_is_deterministic(self): + repo = _InMemorySkillOverrideRepository( + overrides={ + ( + "kg-1", + ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ): { + "z_last": "z", + "a_first": "a", + } + } + ) + service = ExtractionSkillResolutionService(override_repository=repo) + + resolved = await service.resolve_for_session( + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + + # Additional override keys are merged in sorted order for determinism. + assert list(resolved.keys())[-2:] == ["a_first", "z_last"] + From a75608da1393f6eb26ba483488d7fe2db6984246 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 02:57:58 -0400 Subject: [PATCH 12/80] feat(extraction): implement clear-chat reset with session archival (#689) Persist extraction agent sessions and expose scoped APIs for active/list/clear-chat so reset creates a fresh session while preserving archived history and runtime context audit records. Co-authored-by: Cursor --- src/api/extraction/dependencies.py | 20 ++ src/api/extraction/infrastructure/__init__.py | 3 +- .../infrastructure/models/__init__.py | 6 + .../infrastructure/models/agent_session.py | 62 ++++++ .../infrastructure/repositories/__init__.py | 8 + .../repositories/agent_session_repository.py | 107 +++++++++++ src/api/extraction/presentation/__init__.py | 9 + src/api/extraction/presentation/models.py | 47 +++++ src/api/extraction/presentation/routes.py | 123 ++++++++++++ ..._create_extraction_agent_sessions_table.py | 75 ++++++++ src/api/main.py | 4 + .../extraction/presentation/test_routes.py | 178 ++++++++++++++++++ .../unit/extraction/test_architecture.py | 9 +- 13 files changed, 648 insertions(+), 3 deletions(-) create mode 100644 src/api/extraction/dependencies.py create mode 100644 src/api/extraction/infrastructure/models/__init__.py create mode 100644 src/api/extraction/infrastructure/models/agent_session.py create mode 100644 src/api/extraction/infrastructure/repositories/__init__.py create mode 100644 src/api/extraction/infrastructure/repositories/agent_session_repository.py create mode 100644 src/api/extraction/presentation/models.py create mode 100644 src/api/extraction/presentation/routes.py create mode 100644 src/api/infrastructure/migrations/versions/f7d8e9f0a1b2_create_extraction_agent_sessions_table.py create mode 100644 src/api/tests/unit/extraction/presentation/test_routes.py diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py new file mode 100644 index 000000000..6ded27903 --- /dev/null +++ b/src/api/extraction/dependencies.py @@ -0,0 +1,20 @@ +"""FastAPI dependencies for Extraction services.""" + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from extraction.application import ExtractionAgentSessionService +from extraction.infrastructure.repositories import ExtractionAgentSessionRepository +from infrastructure.database.dependencies import get_write_session + + +def get_extraction_agent_session_service( + session: Annotated[AsyncSession, Depends(get_write_session)], +) -> ExtractionAgentSessionService: + """Get ExtractionAgentSessionService instance.""" + return ExtractionAgentSessionService( + repository=ExtractionAgentSessionRepository(session=session) + ) + diff --git a/src/api/extraction/infrastructure/__init__.py b/src/api/extraction/infrastructure/__init__.py index f7a85405f..5aaa4c73a 100644 --- a/src/api/extraction/infrastructure/__init__.py +++ b/src/api/extraction/infrastructure/__init__.py @@ -1,6 +1,7 @@ """Extraction infrastructure adapters and event handlers.""" from extraction.infrastructure.event_handler import ExtractionEventHandler +from extraction.infrastructure.repositories import ExtractionAgentSessionRepository -__all__ = ["ExtractionEventHandler"] +__all__ = ["ExtractionEventHandler", "ExtractionAgentSessionRepository"] diff --git a/src/api/extraction/infrastructure/models/__init__.py b/src/api/extraction/infrastructure/models/__init__.py new file mode 100644 index 000000000..cc9758797 --- /dev/null +++ b/src/api/extraction/infrastructure/models/__init__.py @@ -0,0 +1,6 @@ +"""Extraction infrastructure ORM models.""" + +from extraction.infrastructure.models.agent_session import ExtractionAgentSessionModel + +__all__ = ["ExtractionAgentSessionModel"] + diff --git a/src/api/extraction/infrastructure/models/agent_session.py b/src/api/extraction/infrastructure/models/agent_session.py new file mode 100644 index 000000000..02d282592 --- /dev/null +++ b/src/api/extraction/infrastructure/models/agent_session.py @@ -0,0 +1,62 @@ +"""ORM model for extraction agent sessions.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Index, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from infrastructure.database.models import Base, _utc_now + + +class ExtractionAgentSessionModel(Base): + """Persistence model for long-running extraction sessions.""" + + __tablename__ = "extraction_agent_sessions" + + id: Mapped[str] = mapped_column(String(26), primary_key=True) + user_id: Mapped[str] = mapped_column(String(255), nullable=False) + knowledge_graph_id: Mapped[str] = mapped_column(String(26), nullable=False) + mode: Mapped[str] = mapped_column(String(64), nullable=False) + message_history: Mapped[list[dict]] = mapped_column( + JSONB, nullable=False, default=list + ) + runtime_context: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + insert_default=_utc_now, + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + insert_default=_utc_now, + onupdate=_utc_now, + nullable=False, + ) + archived_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + __table_args__ = ( + Index( + "idx_extract_sessions_scope_active", + "user_id", + "knowledge_graph_id", + "mode", + "archived_at", + ), + Index( + "idx_extract_sessions_scope_updated", + "user_id", + "knowledge_graph_id", + "updated_at", + ), + CheckConstraint( + "mode IN ('schema_bootstrap', 'extraction_operations')", + name="ck_extract_sessions_mode", + ), + ) + diff --git a/src/api/extraction/infrastructure/repositories/__init__.py b/src/api/extraction/infrastructure/repositories/__init__.py new file mode 100644 index 000000000..e39627e27 --- /dev/null +++ b/src/api/extraction/infrastructure/repositories/__init__.py @@ -0,0 +1,8 @@ +"""Extraction infrastructure repositories.""" + +from extraction.infrastructure.repositories.agent_session_repository import ( + ExtractionAgentSessionRepository, +) + +__all__ = ["ExtractionAgentSessionRepository"] + diff --git a/src/api/extraction/infrastructure/repositories/agent_session_repository.py b/src/api/extraction/infrastructure/repositories/agent_session_repository.py new file mode 100644 index 000000000..156301e48 --- /dev/null +++ b/src/api/extraction/infrastructure/repositories/agent_session_repository.py @@ -0,0 +1,107 @@ +"""PostgreSQL repository for extraction agent sessions.""" + +from __future__ import annotations + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode +from extraction.infrastructure.models.agent_session import ExtractionAgentSessionModel +from extraction.ports.repositories import IExtractionAgentSessionRepository + + +class ExtractionAgentSessionRepository(IExtractionAgentSessionRepository): + """Persist and query extraction session records.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def save(self, session: ExtractionAgentSession) -> None: + stmt = select(ExtractionAgentSessionModel).where( + ExtractionAgentSessionModel.id == session.id + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + model = ExtractionAgentSessionModel( + id=session.id, + user_id=session.user_id, + knowledge_graph_id=session.knowledge_graph_id, + mode=session.mode.value, + message_history=session.message_history, + runtime_context=session.runtime_context, + created_at=session.created_at, + updated_at=session.updated_at, + archived_at=session.archived_at, + ) + self._session.add(model) + else: + model.message_history = session.message_history + model.runtime_context = session.runtime_context + model.updated_at = session.updated_at + model.archived_at = session.archived_at + await self._session.flush() + + async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: + stmt = select(ExtractionAgentSessionModel).where( + ExtractionAgentSessionModel.id == session_id + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + return None + return self._to_domain(model) + + async def find_active_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession | None: + stmt = ( + select(ExtractionAgentSessionModel) + .where( + ExtractionAgentSessionModel.user_id == user_id, + ExtractionAgentSessionModel.knowledge_graph_id == knowledge_graph_id, + ExtractionAgentSessionModel.mode == mode.value, + ExtractionAgentSessionModel.archived_at.is_(None), + ) + .order_by(desc(ExtractionAgentSessionModel.updated_at)) + .limit(1) + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + if model is None: + return None + return self._to_domain(model) + + async def list_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + stmt = select(ExtractionAgentSessionModel).where( + ExtractionAgentSessionModel.user_id == user_id, + ExtractionAgentSessionModel.knowledge_graph_id == knowledge_graph_id, + ) + if mode is not None: + stmt = stmt.where(ExtractionAgentSessionModel.mode == mode.value) + stmt = stmt.order_by(desc(ExtractionAgentSessionModel.updated_at)) + result = await self._session.execute(stmt) + return [self._to_domain(model) for model in result.scalars().all()] + + def _to_domain(self, model: ExtractionAgentSessionModel) -> ExtractionAgentSession: + return ExtractionAgentSession( + id=model.id, + user_id=model.user_id, + knowledge_graph_id=model.knowledge_graph_id, + mode=ExtractionSessionMode(model.mode), + message_history=list(model.message_history or []), + runtime_context=dict(model.runtime_context or {}), + created_at=model.created_at, + updated_at=model.updated_at, + archived_at=model.archived_at, + ) + diff --git a/src/api/extraction/presentation/__init__.py b/src/api/extraction/presentation/__init__.py index a87e6bef8..62603fc2b 100644 --- a/src/api/extraction/presentation/__init__.py +++ b/src/api/extraction/presentation/__init__.py @@ -4,3 +4,12 @@ here as the bounded context expands. """ +from fastapi import APIRouter + +from extraction.presentation import routes + +router = APIRouter(prefix="/extraction", tags=["extraction"]) +router.include_router(routes.router) + +__all__ = ["router"] + diff --git a/src/api/extraction/presentation/models.py b/src/api/extraction/presentation/models.py new file mode 100644 index 000000000..995bf0136 --- /dev/null +++ b/src/api/extraction/presentation/models.py @@ -0,0 +1,47 @@ +"""Pydantic models for extraction session APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode + + +class ExtractionSessionResponse(BaseModel): + """API model for extraction session state.""" + + id: str + user_id: str + knowledge_graph_id: str + mode: ExtractionSessionMode + message_history: list[dict[str, Any]] = Field(default_factory=list) + runtime_context: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + archived_at: datetime | None = None + + @classmethod + def from_domain(cls, session: ExtractionAgentSession) -> "ExtractionSessionResponse": + return cls( + id=session.id, + user_id=session.user_id, + knowledge_graph_id=session.knowledge_graph_id, + mode=session.mode, + message_history=session.message_history, + runtime_context=session.runtime_context, + created_at=session.created_at, + updated_at=session.updated_at, + archived_at=session.archived_at, + ) + + +class ExtractionSessionListResponse(BaseModel): + """List response for scoped extraction sessions.""" + + sessions: list[ExtractionSessionResponse] + count: int + diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py new file mode 100644 index 000000000..af6bd2e99 --- /dev/null +++ b/src/api/extraction/presentation/routes.py @@ -0,0 +1,123 @@ +"""HTTP routes for extraction session lifecycle operations.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from extraction.application import ExtractionAgentSessionService +from extraction.dependencies import get_extraction_agent_session_service +from extraction.domain.value_objects import ExtractionSessionMode +from extraction.presentation.models import ( + ExtractionSessionListResponse, + ExtractionSessionResponse, +) +from iam.application.value_objects import CurrentUser +from iam.dependencies.user import get_current_user +from infrastructure.authorization_dependencies import get_spicedb_client +from shared_kernel.authorization.protocols import AuthorizationProvider +from shared_kernel.authorization.types import ( + Permission, + ResourceType, + format_resource, + format_subject, +) + +router = APIRouter(tags=["extraction-sessions"]) + + +async def _assert_kg_edit_permission( + *, + authz: AuthorizationProvider, + current_user: CurrentUser, + knowledge_graph_id: str, +) -> None: + subject = format_subject(ResourceType.USER, current_user.user_id.value) + resource = format_resource(ResourceType.KNOWLEDGE_GRAPH, knowledge_graph_id) + allowed = await authz.check_permission(resource, Permission.EDIT, subject) + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action", + ) + + +@router.get( + "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/active", + response_model=ExtractionSessionResponse, +) +async def get_active_session( + knowledge_graph_id: str, + mode: ExtractionSessionMode, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ + ExtractionAgentSessionService, Depends(get_extraction_agent_session_service) + ], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> ExtractionSessionResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + session = await service.get_or_create_active_session( + user_id=current_user.user_id.value, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + return ExtractionSessionResponse.from_domain(session) + + +@router.get( + "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}", + response_model=ExtractionSessionListResponse, +) +async def list_sessions( + knowledge_graph_id: str, + mode: ExtractionSessionMode, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ + ExtractionAgentSessionService, Depends(get_extraction_agent_session_service) + ], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> ExtractionSessionListResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + sessions = await service.list_sessions( + user_id=current_user.user_id.value, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + payload = [ExtractionSessionResponse.from_domain(session) for session in sessions] + return ExtractionSessionListResponse(sessions=payload, count=len(payload)) + + +@router.post( + "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/clear-chat", + response_model=ExtractionSessionResponse, +) +async def clear_chat( + knowledge_graph_id: str, + mode: ExtractionSessionMode, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ + ExtractionAgentSessionService, Depends(get_extraction_agent_session_service) + ], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> ExtractionSessionResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + session = await service.clear_chat( + user_id=current_user.user_id.value, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + return ExtractionSessionResponse.from_domain(session) + diff --git a/src/api/infrastructure/migrations/versions/f7d8e9f0a1b2_create_extraction_agent_sessions_table.py b/src/api/infrastructure/migrations/versions/f7d8e9f0a1b2_create_extraction_agent_sessions_table.py new file mode 100644 index 000000000..bb8daa853 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/f7d8e9f0a1b2_create_extraction_agent_sessions_table.py @@ -0,0 +1,75 @@ +"""create extraction_agent_sessions table + +Stores per-user/per-knowledge-graph/per-mode extraction sessions, including +chat history, runtime context, and archival timestamps used by Clear chat. + +Revision ID: f7d8e9f0a1b2 +Revises: f6c7d8e9f0a1 +Create Date: 2026-05-14 15:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "f7d8e9f0a1b2" +down_revision: Union[str, Sequence[str], None] = "f6c7d8e9f0a1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create extraction session table and scope indexes.""" + op.create_table( + "extraction_agent_sessions", + sa.Column("id", sa.String(length=26), nullable=False), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("knowledge_graph_id", sa.String(length=26), nullable=False), + sa.Column("mode", sa.String(length=64), nullable=False), + sa.Column( + "message_history", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "runtime_context", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "mode IN ('schema_bootstrap', 'extraction_operations')", + name="ck_extract_sessions_mode", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "idx_extract_sessions_scope_active", + "extraction_agent_sessions", + ["user_id", "knowledge_graph_id", "mode", "archived_at"], + ) + op.create_index( + "idx_extract_sessions_scope_updated", + "extraction_agent_sessions", + ["user_id", "knowledge_graph_id", "updated_at"], + ) + + +def downgrade() -> None: + """Drop extraction session table and indexes.""" + op.drop_index( + "idx_extract_sessions_scope_updated", table_name="extraction_agent_sessions" + ) + op.drop_index( + "idx_extract_sessions_scope_active", table_name="extraction_agent_sessions" + ) + op.drop_table("extraction_agent_sessions") + diff --git a/src/api/main.py b/src/api/main.py index ffeeff0a4..d4deaa401 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -13,6 +13,7 @@ from graph.presentation import routes as graph_routes from iam.presentation import router as iam_router from management.presentation import router as management_router +from extraction.presentation import router as extraction_router from infrastructure.database.dependencies import ( close_database_engines, init_database_engines, @@ -563,6 +564,9 @@ async def kartograph_lifespan(app: FastAPI): # Include Management bounded context routes app.include_router(management_router) +# Include Extraction bounded context routes +app.include_router(extraction_router) + # Include dev utility routes (easy to remove for production) app.include_router(dev_routes.router) diff --git a/src/api/tests/unit/extraction/presentation/test_routes.py b/src/api/tests/unit/extraction/presentation/test_routes.py new file mode 100644 index 000000000..a59923026 --- /dev/null +++ b/src/api/tests/unit/extraction/presentation/test_routes.py @@ -0,0 +1,178 @@ +"""Unit tests for extraction session routes.""" + +from __future__ import annotations + +from dataclasses import replace +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.domain.entities.agent_session import ExtractionAgentSession +from iam.application.value_objects import CurrentUser +from iam.domain.value_objects import TenantId, UserId + + +class _InMemoryAgentSessionRepository: + def __init__(self) -> None: + self._sessions: dict[str, ExtractionAgentSession] = {} + + async def save(self, session: ExtractionAgentSession) -> None: + self._sessions[session.id] = replace(session) + + async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: + session = self._sessions.get(session_id) + return replace(session) if session else None + + async def find_active_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession | None: + for session in self._sessions.values(): + if ( + session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and session.mode == mode + and session.archived_at is None + ): + return replace(session) + return None + + async def list_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + rows = [ + replace(session) + for session in self._sessions.values() + if session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and (mode is None or session.mode == mode) + ] + return sorted(rows, key=lambda s: s.updated_at, reverse=True) + + +class _AllowAllAuthz: + async def check_permission(self, resource: str, permission: str, subject: str) -> bool: + return True + + async def write_relationship(self, resource: str, relation: str, subject: str) -> None: + return None + + async def write_relationships(self, relationships: list) -> None: + return None + + async def delete_relationship(self, resource: str, relation: str, subject: str) -> None: + return None + + async def delete_relationships(self, relationships: list) -> None: + return None + + async def delete_relationships_by_filter( + self, + resource_type: str, + resource_id: str | None = None, + relation: str | None = None, + subject_type: str | None = None, + subject_id: str | None = None, + ) -> None: + return None + + async def bulk_check_permission(self, requests: list) -> set[str]: + return set() + + async def lookup_subjects( + self, + resource: str, + relation: str, + subject_type: str, + optional_subject_relation: str | None = None, + ) -> list: + return [] + + async def lookup_resources( + self, + resource_type: str, + permission: str, + subject: str, + ) -> list[str]: + return [] + + async def read_relationships( + self, + resource_type: str, + resource_id: str | None = None, + relation: str | None = None, + subject_type: str | None = None, + subject_id: str | None = None, + ) -> list: + return [] + + +@pytest.fixture +def extraction_client(): + from extraction.dependencies import get_extraction_agent_session_service + from extraction.presentation import router + from iam.dependencies.user import get_current_user + from infrastructure.authorization_dependencies import get_spicedb_client + + app = FastAPI() + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + app.dependency_overrides[get_extraction_agent_session_service] = lambda: service + app.dependency_overrides[get_current_user] = lambda: CurrentUser( + user_id=UserId(value="user-123"), + username="alice", + tenant_id=TenantId(value="t1"), + ) + app.dependency_overrides[get_spicedb_client] = lambda: _AllowAllAuthz() + app.include_router(router) + return TestClient(app), service + + +class TestExtractionSessionRoutes: + def test_clear_chat_archives_old_session_and_returns_fresh_session( + self, extraction_client + ): + client, _ = extraction_client + active = client.get( + "/extraction/knowledge-graphs/kg-123/sessions/extraction_operations/active" + ) + assert active.status_code == status.HTTP_200_OK + old_id = active.json()["id"] + + response = client.post( + "/extraction/knowledge-graphs/kg-123/sessions/extraction_operations/clear-chat" + ) + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["id"] != old_id + assert payload["message_history"] == [] + assert payload["runtime_context"] == {} + + history_resp = client.get( + "/extraction/knowledge-graphs/kg-123/sessions/extraction_operations" + ) + assert history_resp.status_code == status.HTTP_200_OK + history = history_resp.json()["sessions"] + assert len(history) == 2 + assert any(row["id"] == old_id and row["archived_at"] is not None for row in history) + + def test_active_session_endpoint_returns_existing_active_session( + self, extraction_client + ): + client, _ = extraction_client + first = client.get( + "/extraction/knowledge-graphs/kg-999/sessions/schema_bootstrap/active" + ) + second = client.get( + "/extraction/knowledge-graphs/kg-999/sessions/schema_bootstrap/active" + ) + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_200_OK + assert first.json()["id"] == second.json()["id"] + diff --git a/src/api/tests/unit/extraction/test_architecture.py b/src/api/tests/unit/extraction/test_architecture.py index f6e2fcb2c..6a58ac544 100644 --- a/src/api/tests/unit/extraction/test_architecture.py +++ b/src/api/tests/unit/extraction/test_architecture.py @@ -143,8 +143,13 @@ def test_presentation_does_not_import_other_contexts(self): class TestExtractionBoundedContextIsolation: def test_extraction_does_not_import_iam(self): ( - archrule("extraction_no_iam") - .match("extraction*") + archrule("extraction_inner_no_iam") + .match( + "extraction.domain*", + "extraction.ports*", + "extraction.application*", + "extraction.infrastructure*", + ) .should_not_import("iam*") .check("extraction") ) From a73fa36fdbf849cc3e70d39b6f4e4b2cdf84f4ee Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 03:02:13 -0400 Subject: [PATCH 13/80] feat(management): add data-source commit reference projection (#690) Persist clone-head, last-extraction baseline, and tracked-branch head commit references for data sources and expose them in management API responses for downstream ingestion and UI commit-status workflows. Co-authored-by: Cursor --- ...ommit_reference_columns_to_data_sources.py | 47 +++++++++++++++++++ .../domain/aggregates/data_source.py | 3 ++ .../infrastructure/models/data_source.py | 7 +++ .../repositories/data_source_repository.py | 13 +++++ .../presentation/data_sources/models.py | 24 ++++++++++ .../management/test_data_source_repository.py | 43 +++++++++++++++++ .../presentation/test_data_sources_routes.py | 12 +++++ .../tests/unit/management/test_data_source.py | 13 +++++ 8 files changed, 162 insertions(+) create mode 100644 src/api/infrastructure/migrations/versions/f8e9f0a1b2c3_add_commit_reference_columns_to_data_sources.py diff --git a/src/api/infrastructure/migrations/versions/f8e9f0a1b2c3_add_commit_reference_columns_to_data_sources.py b/src/api/infrastructure/migrations/versions/f8e9f0a1b2c3_add_commit_reference_columns_to_data_sources.py new file mode 100644 index 000000000..a3da811ac --- /dev/null +++ b/src/api/infrastructure/migrations/versions/f8e9f0a1b2c3_add_commit_reference_columns_to_data_sources.py @@ -0,0 +1,47 @@ +"""add commit reference columns to data_sources + +Adds commit reference tracking fields for Git-backed data sources: +- clone_head_commit +- last_extraction_baseline_commit +- tracked_branch_head_commit + +Revision ID: f8e9f0a1b2c3 +Revises: f7d8e9f0a1b2 +Create Date: 2026-05-14 16:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "f8e9f0a1b2c3" +down_revision: Union[str, Sequence[str], None] = "f7d8e9f0a1b2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add nullable commit-reference columns to data_sources.""" + op.add_column( + "data_sources", + sa.Column("clone_head_commit", sa.String(length=64), nullable=True), + ) + op.add_column( + "data_sources", + sa.Column("last_extraction_baseline_commit", sa.String(length=64), nullable=True), + ) + op.add_column( + "data_sources", + sa.Column("tracked_branch_head_commit", sa.String(length=64), nullable=True), + ) + + +def downgrade() -> None: + """Drop commit-reference columns from data_sources.""" + op.drop_column("data_sources", "tracked_branch_head_commit") + op.drop_column("data_sources", "last_extraction_baseline_commit") + op.drop_column("data_sources", "clone_head_commit") + diff --git a/src/api/management/domain/aggregates/data_source.py b/src/api/management/domain/aggregates/data_source.py index 431eb4e12..af30f166e 100644 --- a/src/api/management/domain/aggregates/data_source.py +++ b/src/api/management/domain/aggregates/data_source.py @@ -63,6 +63,9 @@ class DataSource: last_sync_at: datetime | None created_at: datetime updated_at: datetime + clone_head_commit: str | None = None + last_extraction_baseline_commit: str | None = None + tracked_branch_head_commit: str | None = None ontology: Ontology | None = None _pending_events: list[DomainEvent] = field(default_factory=list, repr=False) _probe: DataSourceProbe = field( diff --git a/src/api/management/infrastructure/models/data_source.py b/src/api/management/infrastructure/models/data_source.py index bbbc32e4d..c8b5da737 100644 --- a/src/api/management/infrastructure/models/data_source.py +++ b/src/api/management/infrastructure/models/data_source.py @@ -42,6 +42,13 @@ class DataSourceModel(Base, TimestampMixin): last_sync_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + clone_head_commit: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_extraction_baseline_commit: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + tracked_branch_head_commit: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) ontology_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) __table_args__ = ( diff --git a/src/api/management/infrastructure/repositories/data_source_repository.py b/src/api/management/infrastructure/repositories/data_source_repository.py index 925623c95..de2f23ff6 100644 --- a/src/api/management/infrastructure/repositories/data_source_repository.py +++ b/src/api/management/infrastructure/repositories/data_source_repository.py @@ -80,6 +80,11 @@ async def save(self, data_source: DataSource) -> None: model.schedule_type = data_source.schedule.schedule_type.value model.schedule_value = data_source.schedule.value model.last_sync_at = data_source.last_sync_at + model.clone_head_commit = data_source.clone_head_commit + model.last_extraction_baseline_commit = ( + data_source.last_extraction_baseline_commit + ) + model.tracked_branch_head_commit = data_source.tracked_branch_head_commit model.updated_at = data_source.updated_at model.ontology_json = ontology_json else: @@ -94,6 +99,11 @@ async def save(self, data_source: DataSource) -> None: schedule_type=data_source.schedule.schedule_type.value, schedule_value=data_source.schedule.value, last_sync_at=data_source.last_sync_at, + clone_head_commit=data_source.clone_head_commit, + last_extraction_baseline_commit=( + data_source.last_extraction_baseline_commit + ), + tracked_branch_head_commit=data_source.tracked_branch_head_commit, ontology_json=ontology_json, created_at=data_source.created_at, updated_at=data_source.updated_at, @@ -207,5 +217,8 @@ def _to_domain(self, model: DataSourceModel) -> DataSource: last_sync_at=model.last_sync_at, created_at=model.created_at, updated_at=model.updated_at, + clone_head_commit=model.clone_head_commit, + last_extraction_baseline_commit=model.last_extraction_baseline_commit, + tracked_branch_head_commit=model.tracked_branch_head_commit, ontology=ontology, ) diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index 192f52c41..30a9a57e5 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -189,6 +189,15 @@ class DataSourceResponse(BaseModel): last_sync_at: datetime | None = Field( None, description="When the last sync completed" ) + clone_head_commit: str | None = Field( + None, description="Latest known commit in the local/ingested clone" + ) + last_extraction_baseline_commit: str | None = Field( + None, description="Commit used as baseline during the last extraction run" + ) + tracked_branch_head_commit: str | None = Field( + None, description="Latest known commit at the tracked source branch head" + ) created_at: datetime = Field(..., description="When the DS was created") updated_at: datetime = Field(..., description="When the DS was last updated") ontology: OntologyModel | None = Field( @@ -214,6 +223,9 @@ def from_domain(cls, ds: DataSource) -> DataSourceResponse: adapter_type=ds.adapter_type.value, schedule_type=ds.schedule.schedule_type.value, last_sync_at=ds.last_sync_at, + clone_head_commit=ds.clone_head_commit, + last_extraction_baseline_commit=ds.last_extraction_baseline_commit, + tracked_branch_head_commit=ds.tracked_branch_head_commit, created_at=ds.created_at, updated_at=ds.updated_at, ontology=( @@ -293,6 +305,15 @@ class DataSourceWithSyncResponse(BaseModel): last_sync_at: datetime | None = Field( None, description="When the last sync completed" ) + clone_head_commit: str | None = Field( + None, description="Latest known commit in the local/ingested clone" + ) + last_extraction_baseline_commit: str | None = Field( + None, description="Commit used as baseline during the last extraction run" + ) + tracked_branch_head_commit: str | None = Field( + None, description="Latest known commit at the tracked source branch head" + ) created_at: datetime = Field(..., description="When the DS was created") updated_at: datetime = Field(..., description="When the DS was last updated") ontology: OntologyModel | None = Field( @@ -325,6 +346,9 @@ def from_domain_pair( adapter_type=ds.adapter_type.value, schedule_type=ds.schedule.schedule_type.value, last_sync_at=ds.last_sync_at, + clone_head_commit=ds.clone_head_commit, + last_extraction_baseline_commit=ds.last_extraction_baseline_commit, + tracked_branch_head_commit=ds.tracked_branch_head_commit, created_at=ds.created_at, updated_at=ds.updated_at, ontology=( diff --git a/src/api/tests/integration/management/test_data_source_repository.py b/src/api/tests/integration/management/test_data_source_repository.py index 6699d192f..94815fa1c 100644 --- a/src/api/tests/integration/management/test_data_source_repository.py +++ b/src/api/tests/integration/management/test_data_source_repository.py @@ -112,6 +112,49 @@ async def test_saves_with_credentials_path( assert retrieved is not None assert retrieved.credentials_path == "vault://secrets/github" + @pytest.mark.asyncio + async def test_saves_and_retrieves_commit_references( + self, + data_source_repository: DataSourceRepository, + knowledge_graph_repository: KnowledgeGraphRepository, + async_session, + test_tenant: str, + test_workspace: str, + clean_management_data, + ): + """Should roundtrip Git commit reference tracking fields.""" + kg = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Test KG", + description="For DS commit reference tests", + ) + async with async_session.begin(): + await knowledge_graph_repository.save(kg) + + ds = DataSource.create( + knowledge_graph_id=kg.id.value, + tenant_id=test_tenant, + name="GitHub With Commits", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"repo": "org/repo"}, + ) + ds.clone_head_commit = "1111111111111111111111111111111111111111" + ds.last_extraction_baseline_commit = "2222222222222222222222222222222222222222" + ds.tracked_branch_head_commit = "3333333333333333333333333333333333333333" + + async with async_session.begin(): + await data_source_repository.save(ds) + + retrieved = await data_source_repository.get_by_id(ds.id) + assert retrieved is not None + assert retrieved.clone_head_commit == ds.clone_head_commit + assert ( + retrieved.last_extraction_baseline_commit + == ds.last_extraction_baseline_commit + ) + assert retrieved.tracked_branch_head_commit == ds.tracked_branch_head_commit + class TestDataSourceUpdate: """Tests for updating data sources.""" diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index fe2ad4ab0..7859ff453 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -69,6 +69,9 @@ def sample_data_source(mock_current_user: CurrentUser) -> DataSource: last_sync_at=None, created_at=now, updated_at=now, + clone_head_commit="1111111111111111111111111111111111111111", + last_extraction_baseline_commit="2222222222222222222222222222222222222222", + tracked_branch_head_commit="3333333333333333333333333333333333333333", ) @@ -134,6 +137,15 @@ def test_list_data_sources_returns_200( assert result[0]["id"] == sample_data_source.id.value assert result[0]["name"] == sample_data_source.name assert result[0]["adapter_type"] == sample_data_source.adapter_type.value + assert result[0]["clone_head_commit"] == sample_data_source.clone_head_commit + assert ( + result[0]["last_extraction_baseline_commit"] + == sample_data_source.last_extraction_baseline_commit + ) + assert ( + result[0]["tracked_branch_head_commit"] + == sample_data_source.tracked_branch_head_commit + ) def test_list_data_sources_returns_empty_list( self, diff --git a/src/api/tests/unit/management/test_data_source.py b/src/api/tests/unit/management/test_data_source.py index aa709e4b9..4912c364c 100644 --- a/src/api/tests/unit/management/test_data_source.py +++ b/src/api/tests/unit/management/test_data_source.py @@ -145,6 +145,19 @@ def test_create_sets_last_sync_at_to_none(self): ) assert ds.last_sync_at is None + def test_create_sets_commit_references_to_none(self): + """create() should default commit-reference tracking fields to None.""" + ds = DataSource.create( + knowledge_graph_id="kg-1", + tenant_id="t", + name="Source", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={}, + ) + assert ds.clone_head_commit is None + assert ds.last_extraction_baseline_commit is None + assert ds.tracked_branch_head_commit is None + def test_create_with_credentials_path(self): """create() should store optional credentials_path.""" ds = DataSource.create( From 296aff38a6b006bc4145e28999e699539a7509fb Mon Sep 17 00:00:00 2001 From: aredenba-rh Date: Tue, 26 May 2026 13:54:28 -0400 Subject: [PATCH 14/80] feat(ingestion): resolve baseline and tracked head at sync start (#691) Prepare Git-backed ingestion context by loading data-source commit references, refreshing tracked branch head, and passing baseline commit plus resolved credentials into the ingestion pipeline before packaging begins. Co-authored-by: Cursor # Conflicts: # src/api/ingestion/application/services/ingestion_service.py # src/api/ingestion/infrastructure/event_handler.py # src/api/ingestion/ports/services.py # src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py --- .../application/services/ingestion_service.py | 28 +++-- .../ingestion/infrastructure/event_handler.py | 2 + src/api/ingestion/ports/services.py | 4 + src/api/main.py | 106 ++++++++++++++++- .../application/test_ingestion_service.py | 27 +++++ .../test_ingestion_event_handler.py | 22 ++++ .../unit/test_sessioned_ingestion_handler.py | 107 ++++++++++++++++++ 7 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 src/api/tests/unit/test_sessioned_ingestion_handler.py diff --git a/src/api/ingestion/application/services/ingestion_service.py b/src/api/ingestion/application/services/ingestion_service.py index d8fa626d0..489a10ef4 100644 --- a/src/api/ingestion/application/services/ingestion_service.py +++ b/src/api/ingestion/application/services/ingestion_service.py @@ -15,6 +15,7 @@ from shared_kernel.credential_reader import ICredentialReader from shared_kernel.job_package.builder import JobPackageBuilder from shared_kernel.job_package.value_objects import ( + AdapterCheckpoint, JobPackageId, SyncMode, ) @@ -58,6 +59,8 @@ async def run( connection_config: dict[str, str], credentials_path: str | None, tenant_id: str | None = None, + credentials: dict[str, str] | None = None, + baseline_commit: str | None = None, ) -> JobPackageId: """Run the ingestion pipeline for a data source sync. @@ -69,6 +72,9 @@ async def run( connection_config: Key-value adapter configuration credentials_path: Path for encrypted credentials tenant_id: Tenant ID for credential decryption scoping + credentials: Optional decrypted credentials prepared by caller + baseline_commit: Optional baseline commit SHA used to seed + incremental extraction checkpoint state Returns: The JobPackageId of the produced ZIP archive @@ -85,23 +91,29 @@ async def run( f"Registered adapters: {list(self._adapter_registry.keys())}" ) - credentials: dict[str, str] = {} - if credentials_path: + # Credentials are usually provided by the session-aware event wrapper. + resolved_credentials: dict[str, str] = dict(credentials or {}) + if not resolved_credentials and credentials_path: if not tenant_id: - raise ValueError( - "tenant_id is required when credentials_path is provided" - ) + raise ValueError("tenant_id is required when credentials_path is provided") if self._credential_reader is None: raise RuntimeError("credential_reader is not configured") - credentials = await self._credential_reader.retrieve( + resolved_credentials = await self._credential_reader.retrieve( credentials_path, tenant_id ) + checkpoint = None + if baseline_commit: + checkpoint = AdapterCheckpoint( + schema_version="1.0.0", + data={"commit_sha": baseline_commit}, + ) + # Extract raw items from the adapter using the new ExtractionResult API result = await adapter.extract( connection_config=connection_config, - credentials=credentials, - checkpoint=None, # no checkpoint support yet; always full refresh + credentials=resolved_credentials, + checkpoint=checkpoint, sync_mode=SyncMode.INCREMENTAL, ) diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index 0a9d02b63..e11aeaf2b 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -83,6 +83,8 @@ async def handle( connection_config=payload.get("connection_config", {}), credentials_path=payload.get("credentials_path"), tenant_id=payload.get("tenant_id"), + credentials=payload.get("credentials"), + baseline_commit=payload.get("baseline_commit"), ) except asyncio.CancelledError: # Propagate task cancellation so the event loop can shut down diff --git a/src/api/ingestion/ports/services.py b/src/api/ingestion/ports/services.py index c6306087f..6aee85417 100644 --- a/src/api/ingestion/ports/services.py +++ b/src/api/ingestion/ports/services.py @@ -23,6 +23,8 @@ async def run( connection_config: dict[str, str], credentials_path: str | None, tenant_id: str | None = None, + credentials: dict[str, str] | None = None, + baseline_commit: str | None = None, ) -> JobPackageId: """Run the ingestion pipeline. @@ -33,6 +35,8 @@ async def run( adapter_type: Which adapter to use (e.g. "github") connection_config: Adapter-specific connection configuration credentials_path: Optional Vault path for credentials + credentials: Optional decrypted credentials prepared upstream + baseline_commit: Optional commit SHA used as incremental baseline Returns: JobPackageId for the produced archive diff --git a/src/api/main.py b/src/api/main.py index d4deaa401..5fd81c50c 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -4,9 +4,11 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any +from urllib.parse import urlparse from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +import httpx from util import dev_routes import health_routes @@ -25,6 +27,7 @@ get_cors_settings, get_database_settings, get_iam_settings, + get_management_settings, get_oidc_settings, get_outbox_worker_settings, get_spicedb_settings, @@ -136,13 +139,80 @@ def __init__(self, session_factory: Any) -> None: def supported_event_types(self) -> frozenset[str]: return self._SUPPORTED + @staticmethod + def _parse_github_connection_config( + config: dict[str, str], + ) -> tuple[str, str, str]: + """Parse GitHub config into owner/repo/branch.""" + if "repo_url" in config: + parsed = urlparse(config["repo_url"]) + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) < 2: + raise ValueError("repo_url must include owner and repo") + owner = path_parts[0] + repo = path_parts[1].removesuffix(".git") + branch = config.get("branch", "main") + if len(path_parts) >= 4 and path_parts[2] == "tree": + branch = path_parts[3] + return owner, repo, branch + + if "owner" in config and "repo" in config: + return config["owner"], config["repo"], config.get("branch", "main") + + raise ValueError( + "connection_config must include either 'repo_url' or 'owner'+'repo' keys" + ) + + async def _resolve_github_tracked_head_commit( + self, + connection_config: dict[str, str], + credentials: dict[str, str], + ) -> str | None: + """Resolve latest tracked branch head commit for GitHub sources.""" + try: + owner, repo, branch = self._parse_github_connection_config(connection_config) + except ValueError: + return None + + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + token = credentials.get("token") or credentials.get("access_token") + if token: + headers["Authorization"] = f"Bearer {token}" + + url = f"https://api.github.com/repos/{owner}/{repo}/branches/{branch}" + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + sha = payload.get("commit", {}).get("sha") + return str(sha) if sha else None + async def handle(self, event_type: str, payload: dict[str, Any]) -> None: from infrastructure.outbox.repository import OutboxRepository from ingestion.application.services.ingestion_service import IngestionService from ingestion.infrastructure.event_handler import IngestionEventHandler + from management.domain.value_objects import DataSourceId + from management.infrastructure.repositories.data_source_repository import ( + DataSourceRepository, + ) + from management.infrastructure.repositories.fernet_secret_store import ( + FernetSecretStore, + ) async with self._session_factory() as session: outbox = OutboxRepository(session=session) + ds_repo = DataSourceRepository(session=session, outbox=outbox) + management_settings = get_management_settings() + encryption_keys = management_settings.encryption_key.get_secret_value().split( + "," + ) + credential_reader = FernetSecretStore( + session=session, + encryption_keys=encryption_keys, + ) from ingestion.infrastructure.adapters.github import GitHubAdapter from infrastructure.settings import get_management_settings from management.infrastructure.repositories.fernet_secret_store import ( @@ -177,7 +247,41 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: ingestion_service=ingestion_service, outbox=outbox, ) - await ingestion_handler.handle(event_type, payload) + enriched_payload = dict(payload) + + data_source_id = str(payload.get("data_source_id", "")) + tenant_id = str(payload.get("tenant_id", "")) if payload.get("tenant_id") else "" + adapter_type = str(payload.get("adapter_type", "")) + if data_source_id and adapter_type == "github": + ds = await ds_repo.get_by_id(DataSourceId(value=data_source_id)) + if ds is not None: + if ds.last_extraction_baseline_commit: + enriched_payload["baseline_commit"] = ( + ds.last_extraction_baseline_commit + ) + + credentials: dict[str, str] = {} + if ds.credentials_path and tenant_id: + try: + credentials = await credential_reader.retrieve( + path=ds.credentials_path, + tenant_id=tenant_id, + ) + except KeyError: + credentials = {} + if credentials: + enriched_payload["credentials"] = credentials + + tracked_head = await self._resolve_github_tracked_head_commit( + connection_config=ds.connection_config, + credentials=credentials, + ) + if tracked_head: + enriched_payload["tracked_branch_head_commit"] = tracked_head + ds.tracked_branch_head_commit = tracked_head + await ds_repo.save(ds) + + await ingestion_handler.handle(event_type, enriched_payload) await session.commit() diff --git a/src/api/tests/unit/ingestion/application/test_ingestion_service.py b/src/api/tests/unit/ingestion/application/test_ingestion_service.py index 5329e0e26..9d5be9cd8 100644 --- a/src/api/tests/unit/ingestion/application/test_ingestion_service.py +++ b/src/api/tests/unit/ingestion/application/test_ingestion_service.py @@ -57,6 +57,8 @@ def __init__( ) -> None: self._result = result self._fail = fail + self.last_checkpoint: AdapterCheckpoint | None = None + self.last_credentials: dict[str, str] | None = None async def extract( self, @@ -65,6 +67,8 @@ async def extract( checkpoint: AdapterCheckpoint | None, sync_mode: SyncMode, ) -> ExtractionResult: + self.last_checkpoint = checkpoint + self.last_credentials = credentials if self._fail: raise RuntimeError("credentials expired") if self._result is not None: @@ -178,3 +182,26 @@ async def test_run_handles_empty_changeset(self): credentials_path=None, ) assert isinstance(job_id, JobPackageId) + + async def test_run_uses_baseline_commit_as_checkpoint(self): + """run() should convert baseline_commit into an adapter checkpoint.""" + result = _make_extraction_result() + adapter = _FakeAdapter(result=result) + registry: dict[str, IDatasourceAdapter] = {"github": adapter} + with tempfile.TemporaryDirectory() as tmpdir: + service = IngestionService( + adapter_registry=registry, + work_dir=Path(tmpdir), + ) + await service.run( + sync_run_id="run-001", + data_source_id="ds-001", + knowledge_graph_id="kg-001", + adapter_type="github", + connection_config={"repo": "org/repo"}, + credentials_path=None, + baseline_commit="abc123", + ) + + assert adapter.last_checkpoint is not None + assert adapter.last_checkpoint.data == {"commit_sha": "abc123"} diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index 83716bbcf..37560027c 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -67,6 +67,8 @@ async def run( connection_config: dict[str, str], credentials_path: str | None, tenant_id: str | None = None, + credentials: dict[str, str] | None = None, + baseline_commit: str | None = None, ) -> JobPackageId: self.calls.append( { @@ -74,6 +76,8 @@ async def run( "data_source_id": data_source_id, "knowledge_graph_id": knowledge_graph_id, "adapter_type": adapter_type, + "credentials": credentials, + "baseline_commit": baseline_commit, } ) if self._fail: @@ -150,6 +154,22 @@ async def test_runs_ingestion_on_sync_started( assert call["sync_run_id"] == "run-001" assert call["adapter_type"] == "github" + async def test_passes_baseline_and_credentials_through_payload( + self, + handler: IngestionEventHandler, + ingestion_service: _FakeIngestionService, + ): + """SyncStarted payload baseline/credentials should pass to service.run().""" + payload = _sync_started_payload() + payload["baseline_commit"] = "abc123" + payload["credentials"] = {"token": "secret"} + + await handler.handle("SyncStarted", payload) + + call = ingestion_service.calls[0] + assert call["baseline_commit"] == "abc123" + assert call["credentials"] == {"token": "secret"} + async def test_emits_job_package_produced_on_success( self, handler: IngestionEventHandler, @@ -296,6 +316,8 @@ async def run( # type: ignore[override] connection_config: dict[str, str], credentials_path: str | None, tenant_id: str | None = None, + credentials: dict[str, str] | None = None, + baseline_commit: str | None = None, ) -> JobPackageId: raise asyncio.CancelledError() diff --git a/src/api/tests/unit/test_sessioned_ingestion_handler.py b/src/api/tests/unit/test_sessioned_ingestion_handler.py new file mode 100644 index 000000000..962873c94 --- /dev/null +++ b/src/api/tests/unit/test_sessioned_ingestion_handler.py @@ -0,0 +1,107 @@ +"""Unit tests for session-aware ingestion event context preparation.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from management.domain.aggregates import DataSource +from management.domain.value_objects import DataSourceId, Schedule, ScheduleType +from shared_kernel.datasource_types import DataSourceAdapterType + + +def _make_session_factory(session): + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=False) + factory = MagicMock(return_value=ctx) + return factory + + +def _make_data_source() -> DataSource: + now = datetime.now(UTC) + return DataSource( + id=DataSourceId(value="01JTESTSESSIONHANDLERDATA00"), + knowledge_graph_id="01JTESTSESSIONHANDLERKG0000", + tenant_id="tenant-001", + name="GitHub Source", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"owner": "org", "repo": "repo", "branch": "main"}, + credentials_path="datasource/01JTESTSESSIONHANDLERDATA00/credentials", + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + last_extraction_baseline_commit="baseline123", + ) + + +@pytest.mark.asyncio +async def test_sessioned_ingestion_handler_prepares_commit_context(): + """Wrapper should inject baseline/credentials and refresh tracked head.""" + from main import _SessionedIngestionEventHandler + + session = AsyncMock() + session_factory = _make_session_factory(session) + handler = _SessionedIngestionEventHandler(session_factory=session_factory) + handler._resolve_github_tracked_head_commit = AsyncMock(return_value="head456") # type: ignore[attr-defined] + + outbox_repo = MagicMock() + ds_repo = MagicMock() + secret_store = MagicMock() + ingestion_handler = MagicMock() + ingestion_handler.handle = AsyncMock() + ingestion_service = MagicMock() + + data_source = _make_data_source() + ds_repo.get_by_id = AsyncMock(return_value=data_source) + ds_repo.save = AsyncMock() + secret_store.retrieve = AsyncMock(return_value={"token": "tok"}) + + payload = { + "sync_run_id": "run-001", + "data_source_id": data_source.id.value, + "knowledge_graph_id": data_source.knowledge_graph_id, + "tenant_id": data_source.tenant_id, + "adapter_type": "github", + "connection_config": data_source.connection_config, + "credentials_path": data_source.credentials_path, + } + + management_settings = MagicMock() + management_settings.encryption_key.get_secret_value.return_value = ( + "WlAwWU83a2hSODl2SVY4MHBzQWpwaDBSUHhOU3NfQ3R6aXpvNTJfNE5odz0=" + ) + + with ( + patch("infrastructure.outbox.repository.OutboxRepository", return_value=outbox_repo), + patch( + "management.infrastructure.repositories.data_source_repository.DataSourceRepository", + return_value=ds_repo, + ), + patch( + "management.infrastructure.repositories.fernet_secret_store.FernetSecretStore", + return_value=secret_store, + ), + patch( + "ingestion.application.services.ingestion_service.IngestionService", + return_value=ingestion_service, + ), + patch( + "ingestion.infrastructure.event_handler.IngestionEventHandler", + return_value=ingestion_handler, + ), + patch("main.get_management_settings", return_value=management_settings), + ): + await handler.handle("SyncStarted", payload) + + ingestion_handler.handle.assert_called_once() + call_payload = ingestion_handler.handle.call_args.args[1] + assert call_payload["baseline_commit"] == "baseline123" + assert call_payload["tracked_branch_head_commit"] == "head456" + assert call_payload["credentials"] == {"token": "tok"} + ds_repo.save.assert_awaited_once() + assert data_source.tracked_branch_head_commit == "head456" + From 8ebe2044eaaf8504a5325e0ca1fd03890f4de660 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 10:18:36 -0400 Subject: [PATCH 15/80] feat(ingestion): short-circuit sync when no new commit exists (#692) Skip heavy extraction when tracked branch head equals the last extraction baseline by emitting a completed lifecycle event and recording an explicit no-change audit log entry on the sync run. Co-authored-by: Cursor --- .../ingestion/infrastructure/event_handler.py | 16 +++++ src/api/main.py | 7 ++ .../infrastructure/sync_lifecycle_handler.py | 5 ++ .../test_ingestion_event_handler.py | 21 ++++++ .../test_sync_lifecycle_handler.py | 42 ++++++++++++ .../unit/test_sessioned_ingestion_handler.py | 65 +++++++++++++++++++ 6 files changed, 156 insertions(+) diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index e11aeaf2b..27ea29e5f 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -74,6 +74,22 @@ async def handle( knowledge_graph_id = payload["knowledge_graph_id"] now = datetime.now(UTC) + if payload.get("no_changes_detected") is True: + await self._outbox.append( + event_type="MutationsApplied", + payload={ + "sync_run_id": sync_run_id, + "data_source_id": data_source_id, + "knowledge_graph_id": knowledge_graph_id, + "no_changes_detected": True, + "occurred_at": now.isoformat(), + }, + occurred_at=now, + aggregate_type="sync_run", + aggregate_id=sync_run_id, + ) + return + try: job_package_id = await self._ingestion_service.run( sync_run_id=sync_run_id, diff --git a/src/api/main.py b/src/api/main.py index 5fd81c50c..47629ace1 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -280,6 +280,13 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: enriched_payload["tracked_branch_head_commit"] = tracked_head ds.tracked_branch_head_commit = tracked_head await ds_repo.save(ds) + baseline_commit = enriched_payload.get("baseline_commit") + if ( + isinstance(baseline_commit, str) + and baseline_commit + and baseline_commit == tracked_head + ): + enriched_payload["no_changes_detected"] = True await ingestion_handler.handle(event_type, enriched_payload) await session.commit() diff --git a/src/api/management/infrastructure/sync_lifecycle_handler.py b/src/api/management/infrastructure/sync_lifecycle_handler.py index 5817f6cbb..c33ee1d65 100644 --- a/src/api/management/infrastructure/sync_lifecycle_handler.py +++ b/src/api/management/infrastructure/sync_lifecycle_handler.py @@ -123,6 +123,11 @@ async def handle( sync_run.status = "completed" sync_run.completed_at = now sync_run.logs.append(f"[{now.isoformat()}] Sync completed") + if payload.get("no_changes_detected") is True: + sync_run.logs.append( + f"[{now.isoformat()}] No source changes were detected; " + "heavy extraction was short-circuited." + ) if sync_run.mutation_log_run is not None: sync_run.mutation_log_run.completed_at = now if payload.get("token_usage_total") is not None: diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index 37560027c..408d02bb6 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -199,6 +199,27 @@ async def test_job_package_produced_aggregate_type( assert event["aggregate_type"] == "sync_run" assert event["aggregate_id"] == "run-001" + async def test_short_circuits_when_no_changes_detected( + self, + handler: IngestionEventHandler, + ingestion_service: _FakeIngestionService, + outbox: _FakeOutboxRepository, + ): + """When no_changes_detected is true, heavy ingestion is skipped.""" + payload = _sync_started_payload(sync_run_id="run-004") + payload["no_changes_detected"] = True + payload["tracked_branch_head_commit"] = "abc123" + payload["baseline_commit"] = "abc123" + + await handler.handle("SyncStarted", payload) + + assert ingestion_service.calls == [] + assert len(outbox.appended) == 1 + event = outbox.appended[0] + assert event["event_type"] == "MutationsApplied" + assert event["payload"]["sync_run_id"] == "run-004" + assert event["payload"]["no_changes_detected"] is True + @pytest.mark.asyncio class TestIngestionEventHandlerFailure: diff --git a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py index edc049ecc..035afd82e 100644 --- a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py +++ b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py @@ -392,6 +392,48 @@ async def test_mutations_applied_updates_data_source_last_sync_at( saved_ds = mock_ds_repo.save.call_args[0][0] assert saved_ds.last_sync_at is not None + async def test_mutations_applied_logs_no_changes_short_circuit( + self, + handler: SyncLifecycleHandler, + mock_sync_run_repo: AsyncMock, + mock_ds_repo: AsyncMock, + ): + """No-change short-circuit should leave an explicit audit log entry.""" + from management.domain.aggregates import DataSource + from management.domain.value_objects import DataSourceId, Schedule, ScheduleType + from shared_kernel.datasource_types import DataSourceAdapterType + + run = _make_sync_run(status="ingesting") + mock_sync_run_repo.get_by_id.return_value = run + + now = datetime.now(UTC) + ds = DataSource( + id=DataSourceId(value="ds-001"), + knowledge_graph_id="kg-001", + tenant_id="tenant-001", + name="My DS", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={}, + credentials_path=None, + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + ) + mock_ds_repo.get_by_id.return_value = ds + + await handler.handle( + "MutationsApplied", + _payload( + sync_run_id=run.id, + knowledge_graph_id="kg-001", + no_changes_detected=True, + ), + ) + + saved_run: DataSourceSyncRun = mock_sync_run_repo.save.call_args[0][0] + assert any("No source changes were detected" in line for line in saved_run.logs) + @pytest.mark.asyncio class TestMutationApplicationFailedTransition: diff --git a/src/api/tests/unit/test_sessioned_ingestion_handler.py b/src/api/tests/unit/test_sessioned_ingestion_handler.py index 962873c94..53817275e 100644 --- a/src/api/tests/unit/test_sessioned_ingestion_handler.py +++ b/src/api/tests/unit/test_sessioned_ingestion_handler.py @@ -105,3 +105,68 @@ async def test_sessioned_ingestion_handler_prepares_commit_context(): ds_repo.save.assert_awaited_once() assert data_source.tracked_branch_head_commit == "head456" + +@pytest.mark.asyncio +async def test_sessioned_ingestion_handler_sets_no_changes_flag_when_heads_match(): + """Wrapper should short-circuit when tracked head equals baseline.""" + from main import _SessionedIngestionEventHandler + + session = AsyncMock() + session_factory = _make_session_factory(session) + handler = _SessionedIngestionEventHandler(session_factory=session_factory) + handler._resolve_github_tracked_head_commit = AsyncMock(return_value="baseline123") # type: ignore[attr-defined] + + outbox_repo = MagicMock() + ds_repo = MagicMock() + secret_store = MagicMock() + ingestion_handler = MagicMock() + ingestion_handler.handle = AsyncMock() + ingestion_service = MagicMock() + + data_source = _make_data_source() + ds_repo.get_by_id = AsyncMock(return_value=data_source) + ds_repo.save = AsyncMock() + secret_store.retrieve = AsyncMock(return_value={"token": "tok"}) + + payload = { + "sync_run_id": "run-002", + "data_source_id": data_source.id.value, + "knowledge_graph_id": data_source.knowledge_graph_id, + "tenant_id": data_source.tenant_id, + "adapter_type": "github", + "connection_config": data_source.connection_config, + "credentials_path": data_source.credentials_path, + } + + management_settings = MagicMock() + management_settings.encryption_key.get_secret_value.return_value = ( + "WlAwWU83a2hSODl2SVY4MHBzQWpwaDBSUHhOU3NfQ3R6aXpvNTJfNE5odz0=" + ) + + with ( + patch("infrastructure.outbox.repository.OutboxRepository", return_value=outbox_repo), + patch( + "management.infrastructure.repositories.data_source_repository.DataSourceRepository", + return_value=ds_repo, + ), + patch( + "management.infrastructure.repositories.fernet_secret_store.FernetSecretStore", + return_value=secret_store, + ), + patch( + "ingestion.application.services.ingestion_service.IngestionService", + return_value=ingestion_service, + ), + patch( + "ingestion.infrastructure.event_handler.IngestionEventHandler", + return_value=ingestion_handler, + ), + patch("main.get_management_settings", return_value=management_settings), + ): + await handler.handle("SyncStarted", payload) + + call_payload = ingestion_handler.handle.call_args.args[1] + assert call_payload["baseline_commit"] == "baseline123" + assert call_payload["tracked_branch_head_commit"] == "baseline123" + assert call_payload["no_changes_detected"] is True + From 3c71c939411150c677e63e8e699d58bf99687739 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 10:25:27 -0400 Subject: [PATCH 16/80] feat(management): add baseline-vs-head diff summary endpoint (#693) Expose a data-source diff summary API that compares the last extraction baseline to tracked branch head and returns aggregate counts plus a large-list-safe changed-file preview for maintenance decisions. Co-authored-by: Cursor --- .../management/dependencies/data_source.py | 18 +++ .../git_diff_summary_service.py | 143 ++++++++++++++++++ .../presentation/data_sources/models.py | 36 +++++ .../presentation/data_sources/routes.py | 55 ++++++- .../test_git_diff_summary_service.py | 95 ++++++++++++ .../presentation/test_data_sources_routes.py | 68 +++++++++ 6 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 src/api/management/infrastructure/git_diff_summary_service.py create mode 100644 src/api/tests/unit/management/infrastructure/test_git_diff_summary_service.py diff --git a/src/api/management/dependencies/data_source.py b/src/api/management/dependencies/data_source.py index c0d6a2765..6133ed673 100644 --- a/src/api/management/dependencies/data_source.py +++ b/src/api/management/dependencies/data_source.py @@ -17,6 +17,7 @@ from infrastructure.settings import get_management_settings from management.application.observability import DefaultDataSourceServiceProbe from management.application.services.data_source_service import DataSourceService +from management.infrastructure.git_diff_summary_service import GitDiffSummaryService from management.infrastructure.repositories import ( DataSourceRepository, DataSourceSyncRunRepository, @@ -78,3 +79,20 @@ def get_data_source_service( scope_to_tenant=current_user.tenant_id.value, probe=DefaultDataSourceServiceProbe(), ) + + +def get_git_diff_summary_service( + session: Annotated[AsyncSession, Depends(get_write_session)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> GitDiffSummaryService: + """Get GitDiffSummaryService for commit-baseline file diff summaries.""" + settings = get_management_settings() + encryption_keys = settings.encryption_key.get_secret_value().split(",") + secret_store = FernetSecretStore( + session=session, + encryption_keys=encryption_keys, + ) + return GitDiffSummaryService( + credential_reader=secret_store, + tenant_id=current_user.tenant_id.value, + ) diff --git a/src/api/management/infrastructure/git_diff_summary_service.py b/src/api/management/infrastructure/git_diff_summary_service.py new file mode 100644 index 000000000..2a270bfaa --- /dev/null +++ b/src/api/management/infrastructure/git_diff_summary_service.py @@ -0,0 +1,143 @@ +"""Git-backed diff summary service for data-source maintenance cues.""" + +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import urlparse + +import httpx + +from management.domain.aggregates import DataSource +from shared_kernel.credential_reader import ICredentialReader +from shared_kernel.datasource_types import DataSourceAdapterType + + +@dataclass(frozen=True) +class DiffSummaryResult: + """Aggregate + file-level diff summary between baseline and tracked head.""" + + baseline_commit: str | None + tracked_head_commit: str | None + total_changed_files: int + added_count: int + modified_count: int + removed_count: int + renamed_count: int + files_truncated: bool + changed_files: tuple[dict[str, str], ...] + + +class GitDiffSummaryService: + """Build a Git commit diff summary for a data source.""" + + def __init__( + self, + credential_reader: ICredentialReader, + tenant_id: str, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self._credential_reader = credential_reader + self._tenant_id = tenant_id + self._http_client = http_client + + @staticmethod + def _parse_github_connection_config(config: dict[str, str]) -> tuple[str, str]: + if "repo_url" in config: + parsed = urlparse(config["repo_url"]) + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) < 2: + raise ValueError("repo_url must include owner and repo") + owner = path_parts[0] + repo = path_parts[1].removesuffix(".git") + return owner, repo + + if "owner" in config and "repo" in config: + return config["owner"], config["repo"] + + raise ValueError( + "connection_config must include either 'repo_url' or 'owner'+'repo' keys" + ) + + async def build_summary( + self, + *, + data_source: DataSource, + max_files: int, + ) -> DiffSummaryResult: + """Compute changed-file summary from baseline commit to tracked head.""" + baseline = data_source.last_extraction_baseline_commit + tracked = data_source.tracked_branch_head_commit + if ( + data_source.adapter_type != DataSourceAdapterType.GITHUB + or not baseline + or not tracked + or baseline == tracked + ): + return DiffSummaryResult( + baseline_commit=baseline, + tracked_head_commit=tracked, + total_changed_files=0, + added_count=0, + modified_count=0, + removed_count=0, + renamed_count=0, + files_truncated=False, + changed_files=(), + ) + + owner, repo = self._parse_github_connection_config(data_source.connection_config) + credentials: dict[str, str] = {} + if data_source.credentials_path: + try: + credentials = await self._credential_reader.retrieve( + path=data_source.credentials_path, + tenant_id=self._tenant_id, + ) + except KeyError: + credentials = {} + + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + token = credentials.get("token") or credentials.get("access_token") + if token: + headers["Authorization"] = f"Bearer {token}" + + url = f"https://api.github.com/repos/{owner}/{repo}/compare/{baseline}...{tracked}" + client = self._http_client or httpx.AsyncClient(timeout=30.0) + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + finally: + if self._http_client is None: + await client.aclose() + + files: list[dict[str, str]] = [] + counts = {"added": 0, "modified": 0, "removed": 0, "renamed": 0} + for file in payload.get("files", []): + status = str(file.get("status", "modified")) + if status in counts: + counts[status] += 1 + files.append( + { + "path": str(file.get("filename", "")), + "status": status, + } + ) + + files_truncated = len(files) > max_files + visible_files = tuple(files[:max_files]) + return DiffSummaryResult( + baseline_commit=baseline, + tracked_head_commit=tracked, + total_changed_files=len(files), + added_count=counts["added"], + modified_count=counts["modified"], + removed_count=counts["removed"], + renamed_count=counts["renamed"], + files_truncated=files_truncated, + changed_files=visible_files, + ) + diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index 30a9a57e5..c9c385811 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -245,6 +245,42 @@ class SyncRunLogsResponse(BaseModel): ) +class DiffChangedFileResponse(BaseModel): + """Single changed file entry in a commit diff summary.""" + + path: str = Field(..., description="Repository-relative file path") + status: str = Field( + ..., + description="GitHub compare status (added, modified, removed, renamed, ...)", + ) + + +class DataSourceDiffSummaryResponse(BaseModel): + """Response model for baseline-vs-tracked commit diff summary.""" + + baseline_commit: str | None = Field( + None, + description="Commit baseline used for the previous extraction", + ) + tracked_head_commit: str | None = Field( + None, + description="Latest tracked branch head commit used for comparison", + ) + total_changed_files: int = Field(..., description="Total changed files in compare") + added_count: int = Field(..., description="Number of files added") + modified_count: int = Field(..., description="Number of files modified") + removed_count: int = Field(..., description="Number of files removed") + renamed_count: int = Field(..., description="Number of files renamed") + files_truncated: bool = Field( + ..., + description="True when changed_files is truncated by max_files", + ) + changed_files: list[DiffChangedFileResponse] = Field( + default_factory=list, + description="Changed-file entries, bounded by max_files", + ) + + class SyncRunResponse(BaseModel): """Response model for a data source sync run.""" diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index c268f9a7c..e969a5cf2 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -4,19 +4,22 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from iam.application.value_objects import CurrentUser from iam.dependencies.user import get_current_user from management.application.services.data_source_service import DataSourceService from management.dependencies.data_source import ( get_data_source_service, + get_git_diff_summary_service, get_sync_run_repository, ) +from management.infrastructure.git_diff_summary_service import GitDiffSummaryService from management.ports.exceptions import UnauthorizedError from management.ports.repositories import IDataSourceSyncRunRepository from management.presentation.data_sources.models import ( CreateDataSourceRequest, + DataSourceDiffSummaryResponse, DataSourceListResponse, DataSourceResponse, DataSourceWithSyncResponse, @@ -29,6 +32,56 @@ router = APIRouter(tags=["data-sources"]) +@router.get( + "/data-sources/{ds_id}/diff-summary", + status_code=status.HTTP_200_OK, + summary="Get commit diff summary for a data source", +) +async def get_diff_summary( + ds_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[DataSourceService, Depends(get_data_source_service)], + diff_service: Annotated[ + GitDiffSummaryService, Depends(get_git_diff_summary_service) + ], + max_files: int = Query(default=200, ge=1, le=2000), +) -> DataSourceDiffSummaryResponse: + """Return baseline-vs-tracked diff summary for maintenance readiness cues.""" + try: + ds = await service.get( + user_id=current_user.user_id.value, + ds_id=ds_id, + ) + if ds is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Data source not found", + ) + + summary = await diff_service.build_summary( + data_source=ds, + max_files=max_files, + ) + return DataSourceDiffSummaryResponse( + baseline_commit=summary.baseline_commit, + tracked_head_commit=summary.tracked_head_commit, + total_changed_files=summary.total_changed_files, + added_count=summary.added_count, + modified_count=summary.modified_count, + removed_count=summary.removed_count, + renamed_count=summary.renamed_count, + files_truncated=summary.files_truncated, + changed_files=list(summary.changed_files), + ) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to build diff summary", + ) + + @router.get( "/data-sources", status_code=status.HTTP_200_OK, diff --git a/src/api/tests/unit/management/infrastructure/test_git_diff_summary_service.py b/src/api/tests/unit/management/infrastructure/test_git_diff_summary_service.py new file mode 100644 index 000000000..3e871fd3d --- /dev/null +++ b/src/api/tests/unit/management/infrastructure/test_git_diff_summary_service.py @@ -0,0 +1,95 @@ +"""Unit tests for GitDiffSummaryService.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import httpx +import pytest + +from management.domain.aggregates import DataSource +from management.domain.value_objects import DataSourceId, Schedule, ScheduleType +from management.infrastructure.git_diff_summary_service import GitDiffSummaryService +from shared_kernel.datasource_types import DataSourceAdapterType + + +class _FakeCredentialReader: + def __init__(self, credentials: dict[str, str] | None = None) -> None: + self._credentials = credentials or {} + + async def retrieve(self, path: str, tenant_id: str) -> dict[str, str]: + return dict(self._credentials) + + +def _make_data_source( + *, + baseline: str | None = "aaaa", + tracked: str | None = "bbbb", +) -> DataSource: + now = datetime.now(UTC) + return DataSource( + id=DataSourceId(value="01JTESTDIFFSUMMARYSOURCE000"), + knowledge_graph_id="01JTESTDIFFSUMMARYKG0000000", + tenant_id="tenant-001", + name="GitHub DS", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"owner": "org", "repo": "repo", "branch": "main"}, + credentials_path=None, + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + last_extraction_baseline_commit=baseline, + tracked_branch_head_commit=tracked, + ) + + +@pytest.mark.asyncio +async def test_returns_empty_summary_when_commits_missing(): + """Missing baseline/tracked refs should produce an empty summary.""" + service = GitDiffSummaryService( + credential_reader=_FakeCredentialReader(), + tenant_id="tenant-001", + ) + ds = _make_data_source(baseline=None, tracked="bbbb") + + result = await service.build_summary(data_source=ds, max_files=50) + + assert result.total_changed_files == 0 + assert result.changed_files == () + + +@pytest.mark.asyncio +async def test_truncates_changed_files_when_max_exceeded(): + """Changed-file list should truncate safely for large diffs.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert "compare" in str(request.url) + return httpx.Response( + status_code=200, + json={ + "files": [ + {"filename": "a.py", "status": "added"}, + {"filename": "b.py", "status": "modified"}, + {"filename": "c.py", "status": "removed"}, + ] + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = GitDiffSummaryService( + credential_reader=_FakeCredentialReader(), + tenant_id="tenant-001", + http_client=client, + ) + + result = await service.build_summary(data_source=_make_data_source(), max_files=2) + await client.aclose() + + assert result.total_changed_files == 3 + assert result.files_truncated is True + assert len(result.changed_files) == 2 + assert result.added_count == 1 + assert result.modified_count == 1 + assert result.removed_count == 1 + diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 7859ff453..71adcd6d1 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -18,6 +18,7 @@ from management.application.services.data_source_service import DataSourceService from management.domain.aggregates import DataSource from management.domain.entities import DataSourceSyncRun +from management.infrastructure.git_diff_summary_service import DiffSummaryResult from management.domain.value_objects import ( DataSourceId, Ontology, @@ -43,6 +44,12 @@ def mock_sync_run_repo() -> AsyncMock: return AsyncMock(spec=IDataSourceSyncRunRepository) +@pytest.fixture +def mock_diff_summary_service() -> AsyncMock: + """Mock GitDiffSummaryService for diff-summary route testing.""" + return AsyncMock() + + @pytest.fixture def mock_current_user() -> CurrentUser: """Mock CurrentUser for authentication.""" @@ -94,12 +101,14 @@ def sample_sync_run(sample_data_source: DataSource) -> DataSourceSyncRun: def test_client( mock_ds_service: AsyncMock, mock_sync_run_repo: AsyncMock, + mock_diff_summary_service: AsyncMock, mock_current_user: CurrentUser, ) -> TestClient: """Create TestClient with mocked dependencies.""" from iam.dependencies.user import get_current_user from management.dependencies.data_source import ( get_data_source_service, + get_git_diff_summary_service, get_sync_run_repository, ) from management.presentation import router @@ -108,6 +117,9 @@ def test_client( app.dependency_overrides[get_data_source_service] = lambda: mock_ds_service app.dependency_overrides[get_sync_run_repository] = lambda: mock_sync_run_repo + app.dependency_overrides[get_git_diff_summary_service] = ( + lambda: mock_diff_summary_service + ) app.dependency_overrides[get_current_user] = lambda: mock_current_user app.include_router(router) @@ -728,6 +740,62 @@ def test_list_all_calls_service_with_current_user_id( ) +class TestDataSourceDiffSummaryRoute: + """Tests for GET /management/data-sources/{ds_id}/diff-summary endpoint.""" + + def test_diff_summary_returns_counts_and_changed_files( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_diff_summary_service: AsyncMock, + sample_data_source: DataSource, + ) -> None: + """Diff summary should include aggregate counts + changed file list.""" + mock_ds_service.get.return_value = sample_data_source + mock_diff_summary_service.build_summary.return_value = DiffSummaryResult( + baseline_commit="abc", + tracked_head_commit="def", + total_changed_files=2, + added_count=1, + modified_count=1, + removed_count=0, + renamed_count=0, + files_truncated=False, + changed_files=( + {"path": "src/a.py", "status": "added"}, + {"path": "src/b.py", "status": "modified"}, + ), + ) + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/diff-summary" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["total_changed_files"] == 2 + assert payload["added_count"] == 1 + assert payload["modified_count"] == 1 + assert payload["files_truncated"] is False + assert payload["changed_files"][0]["path"] == "src/a.py" + + def test_diff_summary_returns_404_when_data_source_inaccessible( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_diff_summary_service: AsyncMock, + ) -> None: + """Diff summary route should return 404 when DS is not found/authorized.""" + mock_ds_service.get.return_value = None + + response = test_client.get( + "/management/data-sources/01JPQRST1234567890ABCDEFDS/diff-summary" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_diff_summary_service.build_summary.assert_not_called() + + class TestUpdateDataSourceRoute: """Tests for PATCH /management/data-sources/{ds_id} endpoint. From 2dc62cac30191cd5865115f00e81d31c7d75343a Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 10:31:59 -0400 Subject: [PATCH 17/80] feat(dev-ui): add collapsed diff-summary panel for data sources (#694) Show commit-based diff counts immediately on each data source card and render the changed-file list as collapsed-by-default with explicit expand/collapse controls for large-diff safe browsing. Co-authored-by: Cursor --- src/dev-ui/app/pages/data-sources/index.vue | 112 ++++++++++++++++++++ src/dev-ui/app/tests/data-sources.test.ts | 42 ++++++++ 2 files changed, 154 insertions(+) diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index 573c7b6bf..50f702cb3 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -95,7 +95,28 @@ interface DataSourceItem { knowledge_graph_id: string last_sync_at: string | null created_at: string + clone_head_commit?: string | null + last_extraction_baseline_commit?: string | null + tracked_branch_head_commit?: string | null sync_runs?: SyncRun[] + diff_summary?: DataSourceDiffSummary | null +} + +interface DiffChangedFile { + path: string + status: string +} + +interface DataSourceDiffSummary { + baseline_commit: string | null + tracked_head_commit: string | null + total_changed_files: number + added_count: number + modified_count: number + removed_count: number + renamed_count: number + files_truncated: boolean + changed_files: DiffChangedFile[] } interface AdapterType { @@ -595,6 +616,20 @@ async function approveOntology() { const dataSources = ref([]) const loadingDataSources = ref(false) +const expandedDiffLists = ref>({}) + +function isMaintenanceReady(ds: DataSourceItem): boolean { + if (!ds.last_extraction_baseline_commit || !ds.tracked_branch_head_commit) return false + return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit +} + +function isDiffExpanded(dsId: string): boolean { + return expandedDiffLists.value[dsId] === true +} + +function toggleDiffExpanded(dsId: string) { + expandedDiffLists.value[dsId] = !isDiffExpanded(dsId) +} async function loadDataSources() { if (!hasTenant.value) return @@ -623,6 +658,13 @@ async function loadDataSources() { } catch { ds.sync_runs = [] } + try { + ds.diff_summary = await apiFetch( + `/management/data-sources/${ds.id}/diff-summary` + ) + } catch { + ds.diff_summary = null + } all.push(ds) } } catch { @@ -1141,6 +1183,76 @@ async function handleDeleteDs() { + +
+

Commit Status

+
+
+

Local clone commit

+

{{ ds.clone_head_commit ?? '—' }}

+
+
+

Commit during last extraction

+

{{ ds.last_extraction_baseline_commit ?? '—' }}

+
+
+

Tracked branch head commit

+

{{ ds.tracked_branch_head_commit ?? '—' }}

+
+
+ +
+
+
+ {{ ds.diff_summary.total_changed_files }} + changed files + (+{{ ds.diff_summary.added_count }}, + ~{{ ds.diff_summary.modified_count }}, + -{{ ds.diff_summary.removed_count }}, + r{{ ds.diff_summary.renamed_count }}) +
+ + {{ isMaintenanceReady(ds) ? 'New commits available' : 'Up to date' }} + +
+ +
+

+ Changed-file list is collapsed by default for large diffs. +

+ +
+ +
+
+ {{ file.path }} + {{ file.status }} +
+

+ Showing first {{ ds.diff_summary.changed_files.length }} files. Refine or page for full list. +

+
+
+

Sync History

diff --git a/src/dev-ui/app/tests/data-sources.test.ts b/src/dev-ui/app/tests/data-sources.test.ts index 03c1a8d33..224e00ebb 100644 --- a/src/dev-ui/app/tests/data-sources.test.ts +++ b/src/dev-ui/app/tests/data-sources.test.ts @@ -2023,6 +2023,48 @@ describe('Backend API Alignment — Scenario: Resource operations succeed end-to }) }) +describe('Diff summary panel behavior', () => { + it('detects maintenance readiness when tracked head differs from baseline', () => { + function isMaintenanceReady(ds: { + last_extraction_baseline_commit?: string | null + tracked_branch_head_commit?: string | null + }): boolean { + if (!ds.last_extraction_baseline_commit || !ds.tracked_branch_head_commit) return false + return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit + } + + expect( + isMaintenanceReady({ + last_extraction_baseline_commit: 'aaa', + tracked_branch_head_commit: 'bbb', + }), + ).toBe(true) + expect( + isMaintenanceReady({ + last_extraction_baseline_commit: 'aaa', + tracked_branch_head_commit: 'aaa', + }), + ).toBe(false) + }) + + it('keeps changed-file list collapsed by default and toggles on demand', () => { + const expanded: Record = {} + + function isDiffExpanded(dsId: string): boolean { + return expanded[dsId] === true + } + function toggleDiffExpanded(dsId: string) { + expanded[dsId] = !isDiffExpanded(dsId) + } + + expect(isDiffExpanded('ds-1')).toBe(false) + toggleDiffExpanded('ds-1') + expect(isDiffExpanded('ds-1')).toBe(true) + toggleDiffExpanded('ds-1') + expect(isDiffExpanded('ds-1')).toBe(false) + }) +}) + // ── task-082: Ontology Editor — save to backend after post-extraction edit ─── // Spec: "GIVEN a knowledge graph with completed extraction // WHEN the user modifies the ontology From 5e7a5fdbebfeb3aceed08156ea303885a3f2d900 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 13:57:18 -0400 Subject: [PATCH 18/80] feat(management): add commit-refresh actions for data sources (#675) (#695) Add explicit data-source actions to refresh tracked/clone commit references and adopt tracked head as the current extraction baseline. This lets the UI surface per-source changed-file counts with user-controlled commit context updates for maintenance decisioning. Co-authored-by: Cursor --- src/api/main.py | 2 +- .../services/data_source_service.py | 80 +++++++++++++ .../management/dependencies/data_source.py | 20 ++++ .../git_commit_reference_service.py | 89 ++++++++++++++ .../presentation/data_sources/routes.py | 99 ++++++++++++++++ .../application/test_data_source_service.py | 101 ++++++++++++++++ .../test_git_commit_reference_service.py | 109 ++++++++++++++++++ .../presentation/test_data_sources_routes.py | 95 +++++++++++++++ src/dev-ui/app/pages/data-sources/index.vue | 60 ++++++++++ src/dev-ui/app/tests/data-sources.test.ts | 53 +++++++++ 10 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 src/api/management/infrastructure/git_commit_reference_service.py create mode 100644 src/api/tests/unit/management/infrastructure/test_git_commit_reference_service.py diff --git a/src/api/main.py b/src/api/main.py index 47629ace1..2061f1808 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -153,7 +153,7 @@ def _parse_github_connection_config( repo = path_parts[1].removesuffix(".git") branch = config.get("branch", "main") if len(path_parts) >= 4 and path_parts[2] == "tree": - branch = path_parts[3] + branch = "/".join(path_parts[3:]) return owner, repo, branch if "owner" in config and "repo" in config: diff --git a/src/api/management/application/services/data_source_service.py b/src/api/management/application/services/data_source_service.py index a64490357..d17b4f7ff 100644 --- a/src/api/management/application/services/data_source_service.py +++ b/src/api/management/application/services/data_source_service.py @@ -455,6 +455,86 @@ async def update_ontology( return ds + async def refresh_commit_references( + self, + user_id: str, + ds_id: str, + tracked_branch_head_commit: str, + clone_head_commit: str | None = None, + ) -> DataSource: + """Persist refreshed source commit references for a data source. + + Requires MANAGE permission on the data source. This action updates + tracked and clone commit references and initializes extraction baseline + on first refresh so per-source diff counts can be computed immediately. + """ + has_manage = await self._check_permission( + user_id=user_id, + resource_type=ResourceType.DATA_SOURCE, + resource_id=ds_id, + permission=Permission.MANAGE, + ) + if not has_manage: + self._probe.permission_denied( + user_id=user_id, + resource_id=ds_id, + permission=Permission.MANAGE, + ) + raise UnauthorizedError( + f"User {user_id} lacks manage permission on data source {ds_id}" + ) + + ds = await self._ds_repo.get_by_id(DataSourceId(value=ds_id)) + if ds is None or ds.tenant_id != self._scope_to_tenant: + raise ValueError(f"Data source {ds_id} not found") + + resolved_clone_head = clone_head_commit or tracked_branch_head_commit + ds.tracked_branch_head_commit = tracked_branch_head_commit + ds.clone_head_commit = resolved_clone_head + if ds.last_extraction_baseline_commit is None: + ds.last_extraction_baseline_commit = tracked_branch_head_commit + + await self._ds_repo.save(ds) + await self._session.commit() + self._probe.data_source_updated(ds_id=ds_id, name=ds.name) + return ds + + async def adopt_tracked_head_as_baseline( + self, + user_id: str, + ds_id: str, + ) -> DataSource: + """Move extraction baseline to the currently tracked branch head.""" + has_manage = await self._check_permission( + user_id=user_id, + resource_type=ResourceType.DATA_SOURCE, + resource_id=ds_id, + permission=Permission.MANAGE, + ) + if not has_manage: + self._probe.permission_denied( + user_id=user_id, + resource_id=ds_id, + permission=Permission.MANAGE, + ) + raise UnauthorizedError( + f"User {user_id} lacks manage permission on data source {ds_id}" + ) + + ds = await self._ds_repo.get_by_id(DataSourceId(value=ds_id)) + if ds is None or ds.tenant_id != self._scope_to_tenant: + raise ValueError(f"Data source {ds_id} not found") + if not ds.tracked_branch_head_commit: + raise ValueError( + "Cannot adopt tracked branch head as baseline before refs are refreshed" + ) + + ds.last_extraction_baseline_commit = ds.tracked_branch_head_commit + await self._ds_repo.save(ds) + await self._session.commit() + self._probe.data_source_updated(ds_id=ds_id, name=ds.name) + return ds + async def delete( self, user_id: str, diff --git a/src/api/management/dependencies/data_source.py b/src/api/management/dependencies/data_source.py index 6133ed673..911703851 100644 --- a/src/api/management/dependencies/data_source.py +++ b/src/api/management/dependencies/data_source.py @@ -17,6 +17,9 @@ from infrastructure.settings import get_management_settings from management.application.observability import DefaultDataSourceServiceProbe from management.application.services.data_source_service import DataSourceService +from management.infrastructure.git_commit_reference_service import ( + GitCommitReferenceService, +) from management.infrastructure.git_diff_summary_service import GitDiffSummaryService from management.infrastructure.repositories import ( DataSourceRepository, @@ -96,3 +99,20 @@ def get_git_diff_summary_service( credential_reader=secret_store, tenant_id=current_user.tenant_id.value, ) + + +def get_git_commit_reference_service( + session: Annotated[AsyncSession, Depends(get_write_session)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> GitCommitReferenceService: + """Get GitCommitReferenceService for tracked-head refresh actions.""" + settings = get_management_settings() + encryption_keys = settings.encryption_key.get_secret_value().split(",") + secret_store = FernetSecretStore( + session=session, + encryption_keys=encryption_keys, + ) + return GitCommitReferenceService( + credential_reader=secret_store, + tenant_id=current_user.tenant_id.value, + ) diff --git a/src/api/management/infrastructure/git_commit_reference_service.py b/src/api/management/infrastructure/git_commit_reference_service.py new file mode 100644 index 000000000..b2ddcab8b --- /dev/null +++ b/src/api/management/infrastructure/git_commit_reference_service.py @@ -0,0 +1,89 @@ +"""Resolve remote commit references for Git-backed data sources.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import httpx + +from management.domain.aggregates import DataSource +from shared_kernel.credential_reader import ICredentialReader +from shared_kernel.datasource_types import DataSourceAdapterType + + +class GitCommitReferenceService: + """Fetch tracked branch HEAD commit metadata from remote Git providers.""" + + def __init__( + self, + credential_reader: ICredentialReader, + tenant_id: str, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self._credential_reader = credential_reader + self._tenant_id = tenant_id + self._http_client = http_client + + @staticmethod + def _parse_github_connection_config( + config: dict[str, str], + ) -> tuple[str, str, str]: + """Parse GitHub connection settings into owner/repo/branch.""" + if "repo_url" in config: + parsed = urlparse(config["repo_url"]) + path_parts = [part for part in parsed.path.split("/") if part] + if len(path_parts) < 2: + raise ValueError("repo_url must include owner and repo") + owner = path_parts[0] + repo = path_parts[1].removesuffix(".git") + branch = config.get("branch", "main") + if len(path_parts) >= 4 and path_parts[2] == "tree": + branch = "/".join(path_parts[3:]) + return owner, repo, branch + + if "owner" in config and "repo" in config: + return config["owner"], config["repo"], config.get("branch", "main") + + raise ValueError( + "connection_config must include either 'repo_url' or 'owner'+'repo' keys" + ) + + async def resolve_tracked_head_commit(self, data_source: DataSource) -> str | None: + """Resolve tracked branch HEAD commit for GitHub data sources.""" + if data_source.adapter_type != DataSourceAdapterType.GITHUB: + return None + + owner, repo, branch = self._parse_github_connection_config( + data_source.connection_config + ) + + credentials: dict[str, str] = {} + if data_source.credentials_path: + try: + credentials = await self._credential_reader.retrieve( + path=data_source.credentials_path, + tenant_id=self._tenant_id, + ) + except KeyError: + credentials = {} + + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + token = credentials.get("token") or credentials.get("access_token") + if token: + headers["Authorization"] = f"Bearer {token}" + + url = f"https://api.github.com/repos/{owner}/{repo}/branches/{branch}" + client = self._http_client or httpx.AsyncClient(timeout=20.0) + try: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + finally: + if self._http_client is None: + await client.aclose() + + sha = payload.get("commit", {}).get("sha") + return str(sha) if sha else None diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index e969a5cf2..99c99e419 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -11,9 +11,13 @@ from management.application.services.data_source_service import DataSourceService from management.dependencies.data_source import ( get_data_source_service, + get_git_commit_reference_service, get_git_diff_summary_service, get_sync_run_repository, ) +from management.infrastructure.git_commit_reference_service import ( + GitCommitReferenceService, +) from management.infrastructure.git_diff_summary_service import GitDiffSummaryService from management.ports.exceptions import UnauthorizedError from management.ports.repositories import IDataSourceSyncRunRepository @@ -32,6 +36,101 @@ router = APIRouter(tags=["data-sources"]) +@router.post( + "/data-sources/{ds_id}/commit-refs/refresh", + status_code=status.HTTP_200_OK, + summary="Refresh source commit references for a data source", +) +async def refresh_commit_references( + ds_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[DataSourceService, Depends(get_data_source_service)], + commit_ref_service: Annotated[ + GitCommitReferenceService, Depends(get_git_commit_reference_service) + ], +) -> DataSourceResponse: + """Refresh tracked/cloned commit references for a Git-backed data source.""" + try: + ds = await service.get( + user_id=current_user.user_id.value, + ds_id=ds_id, + ) + if ds is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Data source not found", + ) + + tracked_head = await commit_ref_service.resolve_tracked_head_commit(ds) + if tracked_head is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Unable to resolve tracked branch head commit for this data source", + ) + + updated = await service.refresh_commit_references( + user_id=current_user.user_id.value, + ds_id=ds_id, + tracked_branch_head_commit=tracked_head, + clone_head_commit=tracked_head, + ) + return DataSourceResponse.from_domain(updated) + except UnauthorizedError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action", + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to refresh commit references", + ) + + +@router.post( + "/data-sources/{ds_id}/commit-refs/adopt-tracked-head", + status_code=status.HTTP_200_OK, + summary="Adopt tracked branch head as extraction baseline", +) +async def adopt_tracked_head_as_baseline( + ds_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[DataSourceService, Depends(get_data_source_service)], +) -> DataSourceResponse: + """Set extraction baseline commit to the current tracked branch head.""" + try: + updated = await service.adopt_tracked_head_as_baseline( + user_id=current_user.user_id.value, + ds_id=ds_id, + ) + return DataSourceResponse.from_domain(updated) + except UnauthorizedError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action", + ) + except ValueError as e: + detail = str(e) + status_code = ( + status.HTTP_422_UNPROCESSABLE_ENTITY + if "tracked branch head" in detail + else status.HTTP_404_NOT_FOUND + ) + raise HTTPException(status_code=status_code, detail=detail) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to adopt tracked head as baseline", + ) + + @router.get( "/data-sources/{ds_id}/diff-summary", status_code=status.HTTP_200_OK, diff --git a/src/api/tests/unit/management/application/test_data_source_service.py b/src/api/tests/unit/management/application/test_data_source_service.py index 960f49cab..3ebdc3220 100644 --- a/src/api/tests/unit/management/application/test_data_source_service.py +++ b/src/api/tests/unit/management/application/test_data_source_service.py @@ -1030,6 +1030,107 @@ async def test_trigger_sync_creates_sync_run_and_saves_ds( assert ds_probe.sync_requested_calls[0]["ds_id"] == ds.id.value +class TestDataSourceServiceCommitReferenceActions: + """Tests for commit reference refresh/baseline actions.""" + + @pytest.mark.asyncio + async def test_refresh_commit_references_requires_manage_permission( + self, service, authz, ds_repo, user_id + ) -> None: + """refresh_commit_references() must check MANAGE permission.""" + ds = _make_ds() + ds_repo.seed(ds) + authz.grant_all() + + await service.refresh_commit_references( + user_id=user_id, + ds_id=ds.id.value, + tracked_branch_head_commit="abc123", + clone_head_commit="abc123", + ) + + authz.assert_check_called_once( + resource=f"data_source:{ds.id.value}", + permission=Permission.MANAGE, + subject=f"user:{user_id}", + ) + + @pytest.mark.asyncio + async def test_refresh_commit_references_initializes_baseline_when_empty( + self, service, authz, ds_repo, user_id + ) -> None: + """First commit-refresh should initialize extraction baseline.""" + ds = _make_ds() + ds.last_extraction_baseline_commit = None + ds_repo.seed(ds) + authz.grant_all() + + updated = await service.refresh_commit_references( + user_id=user_id, + ds_id=ds.id.value, + tracked_branch_head_commit="abc123", + clone_head_commit="abc123", + ) + + assert updated.tracked_branch_head_commit == "abc123" + assert updated.clone_head_commit == "abc123" + assert updated.last_extraction_baseline_commit == "abc123" + + @pytest.mark.asyncio + async def test_refresh_commit_references_preserves_existing_baseline( + self, service, authz, ds_repo, user_id + ) -> None: + """Refresh should not overwrite an existing extraction baseline.""" + ds = _make_ds() + ds.last_extraction_baseline_commit = "baseline000" + ds_repo.seed(ds) + authz.grant_all() + + updated = await service.refresh_commit_references( + user_id=user_id, + ds_id=ds.id.value, + tracked_branch_head_commit="tracked999", + clone_head_commit="tracked999", + ) + + assert updated.last_extraction_baseline_commit == "baseline000" + assert updated.tracked_branch_head_commit == "tracked999" + + @pytest.mark.asyncio + async def test_adopt_tracked_head_as_baseline_updates_baseline( + self, service, authz, ds_repo, user_id + ) -> None: + """adopt_tracked_head_as_baseline() should copy tracked head to baseline.""" + ds = _make_ds() + ds.last_extraction_baseline_commit = "old-base" + ds.tracked_branch_head_commit = "new-head" + ds_repo.seed(ds) + authz.grant_all() + + updated = await service.adopt_tracked_head_as_baseline( + user_id=user_id, + ds_id=ds.id.value, + ) + + assert updated.last_extraction_baseline_commit == "new-head" + + @pytest.mark.asyncio + async def test_adopt_tracked_head_as_baseline_requires_tracked_head( + self, service, authz, ds_repo, user_id + ) -> None: + """adopt_tracked_head_as_baseline() should reject when tracked head missing.""" + ds = _make_ds() + ds.tracked_branch_head_commit = None + ds_repo.seed(ds) + authz.grant_all() + + with pytest.raises(ValueError, match="tracked branch head"): + await service.adopt_tracked_head_as_baseline( + user_id=user_id, + ds_id=ds.id.value, + ) + + class TestDataSourceServiceListAllForUser: """Unit tests for DataSourceService.list_all_for_user.""" diff --git a/src/api/tests/unit/management/infrastructure/test_git_commit_reference_service.py b/src/api/tests/unit/management/infrastructure/test_git_commit_reference_service.py new file mode 100644 index 000000000..91a0cd85e --- /dev/null +++ b/src/api/tests/unit/management/infrastructure/test_git_commit_reference_service.py @@ -0,0 +1,109 @@ +"""Unit tests for GitCommitReferenceService.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import httpx +import pytest + +from management.domain.aggregates import DataSource +from management.domain.value_objects import DataSourceId, Schedule, ScheduleType +from management.infrastructure.git_commit_reference_service import ( + GitCommitReferenceService, +) +from shared_kernel.datasource_types import DataSourceAdapterType + + +class _FakeCredentialReader: + def __init__(self, credentials: dict[str, str] | None = None) -> None: + self._credentials = credentials or {} + + async def retrieve(self, path: str, tenant_id: str) -> dict[str, str]: + return dict(self._credentials) + + +def _make_data_source( + *, + adapter_type: DataSourceAdapterType = DataSourceAdapterType.GITHUB, + connection_config: dict[str, str] | None = None, + credentials_path: str | None = None, +) -> DataSource: + now = datetime.now(UTC) + return DataSource( + id=DataSourceId(value="01JTESTCOMMITREFSERVICE0000"), + knowledge_graph_id="01JTESTCOMMITREFKG0000000", + tenant_id="tenant-001", + name="GitHub DS", + adapter_type=adapter_type, + connection_config=connection_config + or {"owner": "org", "repo": "repo", "branch": "main"}, + credentials_path=credentials_path, + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + ) + + +def test_parse_github_config_rejects_invalid_repo_url() -> None: + """Malformed GitHub repo URL should raise a clear error.""" + with pytest.raises(ValueError, match="owner and repo"): + GitCommitReferenceService._parse_github_connection_config( + {"repo_url": "https://github.com/owner-only"} + ) + + +@pytest.mark.asyncio +async def test_resolve_tracked_head_uses_branches_endpoint_with_token() -> None: + """Service should call GitHub branches API with PAT when available.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "https://api.github.com/repos/org/repo/branches/main" + assert request.headers.get("Authorization") == "Bearer secret-token" + return httpx.Response( + status_code=200, + json={"commit": {"sha": "abc123"}}, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = GitCommitReferenceService( + credential_reader=_FakeCredentialReader({"access_token": "secret-token"}), + tenant_id="tenant-001", + http_client=client, + ) + ds = _make_data_source(credentials_path="datasource/ds-1/credentials") + + tracked = await service.resolve_tracked_head_commit(ds) + await client.aclose() + + assert tracked == "abc123" + + +@pytest.mark.asyncio +async def test_resolve_tracked_head_parses_repo_url_branch() -> None: + """repo_url tree syntax should map to owner/repo/branch correctly.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert ( + str(request.url) + == "https://api.github.com/repos/openshift-hyperfleet/kartograph/branches/feature/test" + ) + return httpx.Response(status_code=200, json={"commit": {"sha": "head987"}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + service = GitCommitReferenceService( + credential_reader=_FakeCredentialReader(), + tenant_id="tenant-001", + http_client=client, + ) + ds = _make_data_source( + connection_config={ + "repo_url": "https://github.com/openshift-hyperfleet/kartograph/tree/feature/test" + } + ) + + tracked = await service.resolve_tracked_head_commit(ds) + await client.aclose() + + assert tracked == "head987" diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 71adcd6d1..2e64d01bf 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -50,6 +50,12 @@ def mock_diff_summary_service() -> AsyncMock: return AsyncMock() +@pytest.fixture +def mock_commit_reference_service() -> AsyncMock: + """Mock GitCommitReferenceService for commit-ref route testing.""" + return AsyncMock() + + @pytest.fixture def mock_current_user() -> CurrentUser: """Mock CurrentUser for authentication.""" @@ -102,12 +108,14 @@ def test_client( mock_ds_service: AsyncMock, mock_sync_run_repo: AsyncMock, mock_diff_summary_service: AsyncMock, + mock_commit_reference_service: AsyncMock, mock_current_user: CurrentUser, ) -> TestClient: """Create TestClient with mocked dependencies.""" from iam.dependencies.user import get_current_user from management.dependencies.data_source import ( get_data_source_service, + get_git_commit_reference_service, get_git_diff_summary_service, get_sync_run_repository, ) @@ -120,6 +128,9 @@ def test_client( app.dependency_overrides[get_git_diff_summary_service] = ( lambda: mock_diff_summary_service ) + app.dependency_overrides[get_git_commit_reference_service] = ( + lambda: mock_commit_reference_service + ) app.dependency_overrides[get_current_user] = lambda: mock_current_user app.include_router(router) @@ -796,6 +807,90 @@ def test_diff_summary_returns_404_when_data_source_inaccessible( mock_diff_summary_service.build_summary.assert_not_called() +class TestDataSourceCommitReferenceRoutes: + """Tests for commit-reference refresh/baseline endpoints.""" + + def test_refresh_commit_references_returns_updated_data_source( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_commit_reference_service: AsyncMock, + sample_data_source: DataSource, + ) -> None: + """Refresh endpoint should return updated commit references.""" + refreshed = sample_data_source + refreshed.clone_head_commit = "aaa" + refreshed.tracked_branch_head_commit = "aaa" + refreshed.last_extraction_baseline_commit = "aaa" + mock_ds_service.get.return_value = sample_data_source + mock_commit_reference_service.resolve_tracked_head_commit.return_value = "aaa" + mock_ds_service.refresh_commit_references.return_value = refreshed + + response = test_client.post( + f"/management/data-sources/{sample_data_source.id.value}/commit-refs/refresh" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["clone_head_commit"] == "aaa" + assert payload["tracked_branch_head_commit"] == "aaa" + assert payload["last_extraction_baseline_commit"] == "aaa" + + def test_refresh_commit_references_returns_404_when_inaccessible( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_commit_reference_service: AsyncMock, + ) -> None: + """Refresh endpoint should return 404 if DS not found/authorized.""" + mock_ds_service.get.return_value = None + + response = test_client.post( + "/management/data-sources/01JPQRST1234567890ABCDEFDS/commit-refs/refresh" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_ds_service.refresh_commit_references.assert_not_called() + mock_commit_reference_service.resolve_tracked_head_commit.assert_not_called() + + def test_adopt_tracked_head_as_baseline_returns_updated_data_source( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + sample_data_source: DataSource, + ) -> None: + """Adopt endpoint should return DS with baseline moved to tracked head.""" + updated = sample_data_source + updated.last_extraction_baseline_commit = "tracked-head" + updated.tracked_branch_head_commit = "tracked-head" + mock_ds_service.adopt_tracked_head_as_baseline.return_value = updated + + response = test_client.post( + f"/management/data-sources/{sample_data_source.id.value}/commit-refs/adopt-tracked-head" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["last_extraction_baseline_commit"] == "tracked-head" + + def test_adopt_tracked_head_as_baseline_returns_404_for_missing_source( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + sample_data_source: DataSource, + ) -> None: + """Adopt endpoint should return 404 if service reports missing DS.""" + mock_ds_service.adopt_tracked_head_as_baseline.side_effect = ValueError( + "Data source not found" + ) + + response = test_client.post( + f"/management/data-sources/{sample_data_source.id.value}/commit-refs/adopt-tracked-head" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + class TestUpdateDataSourceRoute: """Tests for PATCH /management/data-sources/{ds_id} endpoint. diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index 50f702cb3..f112f9ba9 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -22,6 +22,7 @@ import { ScrollText, FileText, Settings, + RefreshCw, } from 'lucide-vue-next' import { ADAPTERS, @@ -617,6 +618,8 @@ async function approveOntology() { const dataSources = ref([]) const loadingDataSources = ref(false) const expandedDiffLists = ref>({}) +const refreshingCommitRefs = ref>({}) +const adoptingBaselines = ref>({}) function isMaintenanceReady(ds: DataSourceItem): boolean { if (!ds.last_extraction_baseline_commit || !ds.tracked_branch_head_commit) return false @@ -631,6 +634,39 @@ function toggleDiffExpanded(dsId: string) { expandedDiffLists.value[dsId] = !isDiffExpanded(dsId) } +async function refreshCommitRefs(dsId: string) { + refreshingCommitRefs.value[dsId] = true + try { + const { apiFetch } = useApiClient() + await apiFetch(`/management/data-sources/${dsId}/commit-refs/refresh`, { + method: 'POST', + }) + toast.success('Commit references refreshed') + await loadDataSources() + } catch { + toast.error('Failed to refresh commit references') + } finally { + refreshingCommitRefs.value[dsId] = false + } +} + +async function adoptTrackedHeadBaseline(dsId: string) { + adoptingBaselines.value[dsId] = true + try { + const { apiFetch } = useApiClient() + await apiFetch(`/management/data-sources/${dsId}/commit-refs/adopt-tracked-head`, { + method: 'POST', + }) + toast.success('Baseline updated to tracked head') + await loadDataSources() + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to update baseline' + toast.error('Failed to update baseline', { description: msg }) + } finally { + adoptingBaselines.value[dsId] = false + } +} + async function loadDataSources() { if (!hasTenant.value) return loadingDataSources.value = true @@ -1200,6 +1236,30 @@ async function handleDeleteDs() {

{{ ds.tracked_branch_head_commit ?? '—' }}

+
+ + +
{ + it('refreshCommitRefs calls refresh endpoint and reloads data sources on success', async () => { + const apiFetch = vi.fn().mockResolvedValue({}) + const loadDataSources = vi.fn().mockResolvedValue(undefined) + const refreshingCommitRefs: Record = {} + + async function refreshCommitRefs(dsId: string) { + refreshingCommitRefs[dsId] = true + try { + await apiFetch(`/management/data-sources/${dsId}/commit-refs/refresh`, { + method: 'POST', + }) + await loadDataSources() + } finally { + refreshingCommitRefs[dsId] = false + } + } + + await refreshCommitRefs('ds-1') + expect(apiFetch).toHaveBeenCalledWith('/management/data-sources/ds-1/commit-refs/refresh', { + method: 'POST', + }) + expect(loadDataSources).toHaveBeenCalledOnce() + expect(refreshingCommitRefs['ds-1']).toBe(false) + }) + + it('adoptTrackedHeadBaseline calls adopt endpoint and reloads data on success', async () => { + const apiFetch = vi.fn().mockResolvedValue({}) + const loadDataSources = vi.fn().mockResolvedValue(undefined) + const adoptingBaselines: Record = {} + + async function adoptTrackedHeadBaseline(dsId: string) { + adoptingBaselines[dsId] = true + try { + await apiFetch(`/management/data-sources/${dsId}/commit-refs/adopt-tracked-head`, { + method: 'POST', + }) + await loadDataSources() + } finally { + adoptingBaselines[dsId] = false + } + } + + await adoptTrackedHeadBaseline('ds-2') + expect(apiFetch).toHaveBeenCalledWith( + '/management/data-sources/ds-2/commit-refs/adopt-tracked-head', + { method: 'POST' }, + ) + expect(loadDataSources).toHaveBeenCalledOnce() + expect(adoptingBaselines['ds-2']).toBe(false) + }) +}) + describe('Diff summary panel behavior', () => { it('detects maintenance readiness when tracked head differs from baseline', () => { function isMaintenanceReady(ds: { From 6b11bbc98b11b36705ed540b89d81b77a2272357 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:01:06 -0400 Subject: [PATCH 19/80] chore(skills): add parallel blocker-question protocol (#696) Strengthen subagent delivery guidance with a parallel execution model, required context packs, and a blocker-question escalation flow so multiple agents can pause and ask focused questions without serializing delivery. Co-authored-by: Cursor --- skills/subagent-delivery/SKILL.md | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/skills/subagent-delivery/SKILL.md b/skills/subagent-delivery/SKILL.md index 0273dba82..3b9156390 100644 --- a/skills/subagent-delivery/SKILL.md +++ b/skills/subagent-delivery/SKILL.md @@ -4,12 +4,27 @@ description: > Executes a GitHub issue end-to-end with consistent branch, test, PR, and merge behavior. Use when implementing units of work with sub-agents, preparing pull requests, resolving merge conflicts, or when the user asks to run issue-by-issue delivery into feature/manage-knowledge-graph. + Supports parallel delivery waves with explicit blocker-question escalation. --- # Subagent Delivery Protocol Follow this protocol for every assigned issue. +## Parallel Execution Model + +Use this model whenever multiple issues are independent: + +1. One subagent per issue branch. +2. Shared target branch: `feature/manage-knowledge-graph`. +3. No shared working branch between agents. +4. Each subagent works to PR-ready state independently. +5. Merge in dependency order (foundational backend before UI polish when coupled). + +If two issues touch the same files heavily, either: +- serialize those two issues, or +- split scope so each agent owns non-overlapping symbols. + ## Scope and Inputs Before coding, gather: @@ -17,9 +32,36 @@ Before coding, gather: 1. Issue number and acceptance criteria. 2. Target branch: `feature/manage-knowledge-graph`. 3. Current repository state (`git status`, `git branch -vv`). +4. Context pack (required): + - relevant specs under `specs/` + - bounded context ownership (management/ingestion/extraction/graph/querying/ui) + - existing tests near touched code + - architectural constraints from `AGENTS.md` If acceptance criteria are ambiguous, ask one focused question before implementation. +## Blocker Question Protocol (Required) + +Subagents must be able to stop and ask questions immediately. + +Trigger a blocker question when any of these is true: + +1. More than one valid interpretation of acceptance criteria. +2. Missing security/tenancy/authorization decision. +3. Required external behavior is unspecified. +4. You would otherwise make an irreversible guess. + +When blocked: + +1. Stop implementation at the decision boundary. +2. Ask one concise question in the active agent chat immediately. +3. Include: + - what is ambiguous + - 2-3 concrete options + - recommended option and why +4. If working from a GitHub issue, mirror the same question as an issue comment so the orchestrator can batch unresolved questions across agents. +5. Continue only non-blocked work; do not guess on blocked decisions. + ## Git Workflow 1. Ensure local target branch is up to date: @@ -39,6 +81,7 @@ If acceptance criteria are ambiguous, ask one focused question before implementa 4. Run focused tests first, then broader suite for touched context. 5. Run lints/type checks for changed files when applicable. 6. If behavior depends on configuration, use settings/DI instead of hardcoding. +7. If new ambiguity appears mid-implementation, invoke the Blocker Question Protocol. ## PR Workflow @@ -61,6 +104,7 @@ If acceptance criteria are ambiguous, ask one focused question before implementa ``` 4. Link the issue in PR body using `Closes #` when appropriate. +5. If any assumptions were made, include an explicit assumptions list in PR body. ## Merge and Conflict Handling @@ -73,6 +117,15 @@ If acceptance criteria are ambiguous, ask one focused question before implementa 4. Re-run tests after conflict resolution. 5. Merge into `feature/manage-knowledge-graph` only after verification. +## Orchestrator Handoff Contract + +Each subagent must hand back: + +1. Branch name and PR URL. +2. Test commands run with pass/fail status. +3. Any unresolved questions (if still blocked). +4. Any assumptions that were taken and why they are safe. + ## Non-Negotiables - Do not use destructive git commands. From 9d1e9eb9f37be48107fb0ef422998d5b009e3f2b Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:07:24 -0400 Subject: [PATCH 20/80] feat(extraction): ship mode-specific prompt bundles and skill packs (#678) (#697) Add structured mode-specific agent configuration (system prompt, hierarchy, guardrails, and skill pack defaults) and wire session initialization to resolve and persist the configuration per knowledge graph scope. Co-authored-by: Cursor --- .../application/agent_session_service.py | 21 ++++++- .../application/skill_resolution_service.py | 63 ++++++++++++++++++- src/api/extraction/dependencies.py | 16 ++++- src/api/extraction/infrastructure/__init__.py | 11 +++- .../infrastructure/repositories/__init__.py | 5 +- .../repositories/skill_override_repository.py | 22 +++++++ .../application/test_agent_session_service.py | 53 ++++++++++++++++ .../test_skill_resolution_service.py | 22 ++++--- 8 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 src/api/extraction/infrastructure/repositories/skill_override_repository.py diff --git a/src/api/extraction/application/agent_session_service.py b/src/api/extraction/application/agent_session_service.py index 646489e7c..61380a1c0 100644 --- a/src/api/extraction/application/agent_session_service.py +++ b/src/api/extraction/application/agent_session_service.py @@ -4,6 +4,9 @@ from ulid import ULID +from extraction.application.skill_resolution_service import ( + ExtractionSkillResolutionService, +) from extraction.domain.entities.agent_session import ExtractionAgentSession from extraction.domain.value_objects import ExtractionSessionMode from extraction.ports.repositories import IExtractionAgentSessionRepository @@ -12,8 +15,13 @@ class ExtractionAgentSessionService: """Orchestrates session create/get/list/archive behaviors by scope.""" - def __init__(self, repository: IExtractionAgentSessionRepository) -> None: + def __init__( + self, + repository: IExtractionAgentSessionRepository, + skill_resolution_service: ExtractionSkillResolutionService | None = None, + ) -> None: self._repository = repository + self._skill_resolution_service = skill_resolution_service async def get_or_create_active_session( self, @@ -35,6 +43,17 @@ async def get_or_create_active_session( knowledge_graph_id=knowledge_graph_id, mode=mode, ) + if self._skill_resolution_service is not None: + resolved = await self._skill_resolution_service.resolve_for_session( + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + session.runtime_context["agent_configuration"] = { + "system_prompt": resolved.system_prompt, + "prompt_hierarchy": list(resolved.prompt_hierarchy), + "guardrails": list(resolved.guardrails), + "skills": dict(resolved.skills), + } await self._repository.save(session) return session diff --git a/src/api/extraction/application/skill_resolution_service.py b/src/api/extraction/application/skill_resolution_service.py index a2d1b7f83..0cf5f137f 100644 --- a/src/api/extraction/application/skill_resolution_service.py +++ b/src/api/extraction/application/skill_resolution_service.py @@ -2,12 +2,65 @@ from __future__ import annotations +from dataclasses import dataclass + from extraction.domain.value_objects import ExtractionSessionMode from extraction.ports.repositories import IExtractionSkillOverrideRepository +@dataclass(frozen=True) +class ResolvedExtractionSkillPack: + """Resolved mode-aware prompt bundle for agent runtime.""" + + system_prompt: str + prompt_hierarchy: tuple[str, ...] + guardrails: tuple[str, ...] + skills: dict[str, str] + + +_GLOBAL_PROMPT_SETTINGS: dict[ExtractionSessionMode, dict[str, object]] = { + ExtractionSessionMode.SCHEMA_BOOTSTRAP: { + "system_prompt": ( + "You are the schema bootstrap guide. Start by understanding the user's " + "capabilities, goals, and domain intent before proposing a graph model." + ), + "prompt_hierarchy": ( + "platform_security_constraints", + "tenant_and_knowledge_graph_scope", + "schema_bootstrap_goals_and_capabilities_intake", + "mode_specific_skill_pack", + ), + "guardrails": ( + "Prefer mutation-log compatible schema guidance over ad-hoc writes.", + "Never fabricate repository content or credentials.", + "Keep recommendations scoped to the active knowledge graph.", + ), + }, + ExtractionSessionMode.EXTRACTION_OPERATIONS: { + "system_prompt": ( + "You are the extraction operations guide. Optimize for safe incremental " + "job setup, scoped maintenance, and auditable mutation outcomes." + ), + "prompt_hierarchy": ( + "platform_security_constraints", + "tenant_and_knowledge_graph_scope", + "extraction_operations_objective", + "mode_specific_skill_pack", + ), + "guardrails": ( + "All write paths must remain mutation-log auditable.", + "Treat schema edits as secondary unless explicitly requested.", + "Avoid broad destructive changes without explicit confirmation.", + ), + }, +} + _GLOBAL_SKILL_TEMPLATES: dict[ExtractionSessionMode, dict[str, str]] = { ExtractionSessionMode.SCHEMA_BOOTSTRAP: { + "capabilities_intake": ( + "Begin by asking for user capabilities/goals and confirm whether they " + "want a first-pass schema attempt or guided co-design." + ), "schema_modeling": ( "Guide the user to define complete entity and relationship types " "with clear labels, constraints, and required properties." @@ -44,7 +97,8 @@ async def resolve_for_session( self, knowledge_graph_id: str, mode: ExtractionSessionMode, - ) -> dict[str, str]: + ) -> ResolvedExtractionSkillPack: + prompt_settings = _GLOBAL_PROMPT_SETTINGS[mode] base_templates = dict(_GLOBAL_SKILL_TEMPLATES[mode]) overrides = await self._override_repository.get_overrides_for_knowledge_graph( knowledge_graph_id=knowledge_graph_id, @@ -62,5 +116,10 @@ async def resolve_for_session( if key not in resolved: resolved[key] = overrides[key] - return resolved + return ResolvedExtractionSkillPack( + system_prompt=str(prompt_settings["system_prompt"]), + prompt_hierarchy=tuple(prompt_settings["prompt_hierarchy"]), + guardrails=tuple(prompt_settings["guardrails"]), + skills=resolved, + ) diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index 6ded27903..e720e81d8 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -5,8 +5,14 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from extraction.application import ExtractionAgentSessionService -from extraction.infrastructure.repositories import ExtractionAgentSessionRepository +from extraction.application import ( + ExtractionAgentSessionService, + ExtractionSkillResolutionService, +) +from extraction.infrastructure.repositories import ( + ExtractionAgentSessionRepository, + ExtractionSkillOverrideRepository, +) from infrastructure.database.dependencies import get_write_session @@ -14,7 +20,11 @@ def get_extraction_agent_session_service( session: Annotated[AsyncSession, Depends(get_write_session)], ) -> ExtractionAgentSessionService: """Get ExtractionAgentSessionService instance.""" + skill_resolution_service = ExtractionSkillResolutionService( + override_repository=ExtractionSkillOverrideRepository() + ) return ExtractionAgentSessionService( - repository=ExtractionAgentSessionRepository(session=session) + repository=ExtractionAgentSessionRepository(session=session), + skill_resolution_service=skill_resolution_service, ) diff --git a/src/api/extraction/infrastructure/__init__.py b/src/api/extraction/infrastructure/__init__.py index 5aaa4c73a..3ffd68fe2 100644 --- a/src/api/extraction/infrastructure/__init__.py +++ b/src/api/extraction/infrastructure/__init__.py @@ -1,7 +1,14 @@ """Extraction infrastructure adapters and event handlers.""" from extraction.infrastructure.event_handler import ExtractionEventHandler -from extraction.infrastructure.repositories import ExtractionAgentSessionRepository +from extraction.infrastructure.repositories import ( + ExtractionAgentSessionRepository, + ExtractionSkillOverrideRepository, +) -__all__ = ["ExtractionEventHandler", "ExtractionAgentSessionRepository"] +__all__ = [ + "ExtractionEventHandler", + "ExtractionAgentSessionRepository", + "ExtractionSkillOverrideRepository", +] diff --git a/src/api/extraction/infrastructure/repositories/__init__.py b/src/api/extraction/infrastructure/repositories/__init__.py index e39627e27..00204177a 100644 --- a/src/api/extraction/infrastructure/repositories/__init__.py +++ b/src/api/extraction/infrastructure/repositories/__init__.py @@ -3,6 +3,9 @@ from extraction.infrastructure.repositories.agent_session_repository import ( ExtractionAgentSessionRepository, ) +from extraction.infrastructure.repositories.skill_override_repository import ( + ExtractionSkillOverrideRepository, +) -__all__ = ["ExtractionAgentSessionRepository"] +__all__ = ["ExtractionAgentSessionRepository", "ExtractionSkillOverrideRepository"] diff --git a/src/api/extraction/infrastructure/repositories/skill_override_repository.py b/src/api/extraction/infrastructure/repositories/skill_override_repository.py new file mode 100644 index 000000000..d274b51f6 --- /dev/null +++ b/src/api/extraction/infrastructure/repositories/skill_override_repository.py @@ -0,0 +1,22 @@ +"""Infrastructure repository for extraction skill overrides.""" + +from __future__ import annotations + +from extraction.domain.value_objects import ExtractionSessionMode +from extraction.ports.repositories import IExtractionSkillOverrideRepository + + +class ExtractionSkillOverrideRepository(IExtractionSkillOverrideRepository): + """Return KG-specific skill overrides. + + Current tracer-bullet implementation returns no overrides. This still allows + the resolution service to compose deterministic mode defaults and provides a + stable extension point for persisted KG overrides. + """ + + async def get_overrides_for_knowledge_graph( + self, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> dict[str, str]: + return {} diff --git a/src/api/tests/unit/extraction/application/test_agent_session_service.py b/src/api/tests/unit/extraction/application/test_agent_session_service.py index aade8550f..5d62f9ba5 100644 --- a/src/api/tests/unit/extraction/application/test_agent_session_service.py +++ b/src/api/tests/unit/extraction/application/test_agent_session_service.py @@ -55,6 +55,39 @@ async def list_by_scope( return sorted(sessions, key=lambda s: s.updated_at, reverse=True) +class _StaticSkillResolutionService: + def __init__(self) -> None: + self.calls: list[tuple[str, ExtractionSessionMode]] = [] + + async def resolve_for_session( + self, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ): + self.calls.append((knowledge_graph_id, mode)) + if mode == ExtractionSessionMode.SCHEMA_BOOTSTRAP: + return type( + "_Resolved", + (), + { + "system_prompt": "Bootstrap system prompt", + "prompt_hierarchy": ("platform", "mode"), + "guardrails": ("never leak credentials",), + "skills": {"schema_modeling": "bootstrap schema guidance"}, + }, + )() + return type( + "_Resolved", + (), + { + "system_prompt": "Operations system prompt", + "prompt_hierarchy": ("platform", "operations"), + "guardrails": ("mutation logs only",), + "skills": {"job_setup": "operations setup guidance"}, + }, + )() + + @pytest.mark.asyncio class TestExtractionAgentSessionService: async def test_reuses_active_session_for_same_scope(self): @@ -159,3 +192,23 @@ async def test_list_sessions_includes_archived_history(self): assert len(sessions) == 2 assert any(session.id == first.id and session.archived_at is not None for session in sessions) + async def test_new_session_initializes_runtime_context_from_skill_resolution(self): + repo = _InMemoryAgentSessionRepository() + skill_resolution = _StaticSkillResolutionService() + service = ExtractionAgentSessionService( + repository=repo, + skill_resolution_service=skill_resolution, + ) + + session = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + + assert "agent_configuration" in session.runtime_context + config = session.runtime_context["agent_configuration"] + assert config["system_prompt"] == "Bootstrap system prompt" + assert config["skills"]["schema_modeling"] == "bootstrap schema guidance" + assert skill_resolution.calls == [("kg-1", ExtractionSessionMode.SCHEMA_BOOTSTRAP)] + diff --git a/src/api/tests/unit/extraction/application/test_skill_resolution_service.py b/src/api/tests/unit/extraction/application/test_skill_resolution_service.py index 90bb67082..fa5167b54 100644 --- a/src/api/tests/unit/extraction/application/test_skill_resolution_service.py +++ b/src/api/tests/unit/extraction/application/test_skill_resolution_service.py @@ -34,8 +34,12 @@ async def test_bootstrap_mode_uses_bootstrap_defaults(self): mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, ) - assert "schema_modeling" in resolved - assert "prepopulation_validation" in resolved + assert "schema_modeling" in resolved.skills + assert "prepopulation_validation" in resolved.skills + assert "capabilities_intake" in resolved.skills + assert "goal" in resolved.system_prompt.lower() + assert len(resolved.prompt_hierarchy) > 0 + assert len(resolved.guardrails) > 0 async def test_extraction_mode_uses_extraction_defaults(self): service = ExtractionSkillResolutionService( @@ -47,8 +51,12 @@ async def test_extraction_mode_uses_extraction_defaults(self): mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, ) - assert "job_setup" in resolved - assert "minor_edits" in resolved + assert "job_setup" in resolved.skills + assert "minor_edits" in resolved.skills + assert "schema_edits_secondary" in resolved.skills + assert "extraction" in resolved.system_prompt.lower() + assert len(resolved.prompt_hierarchy) > 0 + assert len(resolved.guardrails) > 0 async def test_kg_overrides_replace_matching_template_and_append_new(self): repo = _InMemorySkillOverrideRepository( @@ -69,8 +77,8 @@ async def test_kg_overrides_replace_matching_template_and_append_new(self): mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, ) - assert resolved["job_setup"] == "KG-specific job setup instructions" - assert resolved["custom_review"] == "Custom review flow" + assert resolved.skills["job_setup"] == "KG-specific job setup instructions" + assert resolved.skills["custom_review"] == "Custom review flow" async def test_override_merge_is_deterministic(self): repo = _InMemorySkillOverrideRepository( @@ -92,5 +100,5 @@ async def test_override_merge_is_deterministic(self): ) # Additional override keys are merged in sorted order for determinism. - assert list(resolved.keys())[-2:] == ["a_first", "z_last"] + assert list(resolved.skills.keys())[-2:] == ["a_first", "z_last"] From 3f1da3204c8577c4d405de8ef71d90f90c316196 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:11:22 -0400 Subject: [PATCH 21/80] feat(extraction): add bootstrap capabilities intake dual-path flow (#677) (#698) Seed schema bootstrap sessions with a capabilities-intake prompt that offers first-pass or guided co-design paths, and persist the selected path/capability summary in session runtime context so the conversation remains continuous across requests. Co-authored-by: Cursor --- .../application/agent_session_service.py | 49 ++++++++++++++++++- src/api/extraction/domain/value_objects.py | 7 +++ src/api/extraction/presentation/models.py | 12 ++++- src/api/extraction/presentation/routes.py | 28 +++++++++++ .../application/test_agent_session_service.py | 41 +++++++++++++++- .../extraction/presentation/test_routes.py | 21 ++++++++ 6 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/api/extraction/application/agent_session_service.py b/src/api/extraction/application/agent_session_service.py index 61380a1c0..b4a4c6a03 100644 --- a/src/api/extraction/application/agent_session_service.py +++ b/src/api/extraction/application/agent_session_service.py @@ -2,13 +2,15 @@ from __future__ import annotations +from datetime import UTC, datetime + from ulid import ULID from extraction.application.skill_resolution_service import ( ExtractionSkillResolutionService, ) from extraction.domain.entities.agent_session import ExtractionAgentSession -from extraction.domain.value_objects import ExtractionSessionMode +from extraction.domain.value_objects import BootstrapIntakePath, ExtractionSessionMode from extraction.ports.repositories import IExtractionAgentSessionRepository @@ -23,6 +25,15 @@ def __init__( self._repository = repository self._skill_resolution_service = skill_resolution_service + @staticmethod + def _build_bootstrap_intake_prompt() -> str: + return ( + "Before we draft schema types, share your capabilities and goals for this " + "knowledge graph. Then choose one path: " + "(1) immediate first-pass schema attempt, or " + "(2) guided question-by-question co-design." + ) + async def get_or_create_active_session( self, user_id: str, @@ -54,6 +65,19 @@ async def get_or_create_active_session( "guardrails": list(resolved.guardrails), "skills": dict(resolved.skills), } + if mode == ExtractionSessionMode.SCHEMA_BOOTSTRAP: + session.message_history.append( + {"role": "assistant", "content": self._build_bootstrap_intake_prompt()} + ) + session.runtime_context["bootstrap_intake"] = { + "status": "awaiting_path_selection", + "selected_path": None, + "capabilities_goals": None, + "path_options": [ + BootstrapIntakePath.FIRST_PASS_SCHEMA_ATTEMPT.value, + BootstrapIntakePath.GUIDED_CO_DESIGN.value, + ], + } await self._repository.save(session) return session @@ -99,3 +123,26 @@ async def archive_session(self, session_id: str) -> ExtractionAgentSession | Non await self._repository.save(session) return session + async def set_bootstrap_intake_path_for_active_session( + self, + user_id: str, + knowledge_graph_id: str, + selected_path: BootstrapIntakePath, + capabilities_goals: str | None, + ) -> ExtractionAgentSession: + """Persist bootstrap path selection for session continuity.""" + session = await self.get_or_create_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + intake = dict(session.runtime_context.get("bootstrap_intake", {})) + intake["status"] = "path_selected" + intake["selected_path"] = selected_path.value + intake["capabilities_goals"] = capabilities_goals + intake["selected_at"] = datetime.now(UTC).isoformat() + session.runtime_context["bootstrap_intake"] = intake + session.updated_at = datetime.now(UTC) + await self._repository.save(session) + return session + diff --git a/src/api/extraction/domain/value_objects.py b/src/api/extraction/domain/value_objects.py index c12cdfd2b..22ec0470c 100644 --- a/src/api/extraction/domain/value_objects.py +++ b/src/api/extraction/domain/value_objects.py @@ -9,3 +9,10 @@ class ExtractionSessionMode(StrEnum): SCHEMA_BOOTSTRAP = "schema_bootstrap" EXTRACTION_OPERATIONS = "extraction_operations" + +class BootstrapIntakePath(StrEnum): + """User-selected bootstrap onboarding path.""" + + FIRST_PASS_SCHEMA_ATTEMPT = "first_pass_schema_attempt" + GUIDED_CO_DESIGN = "guided_co_design" + diff --git a/src/api/extraction/presentation/models.py b/src/api/extraction/presentation/models.py index 995bf0136..781791ad7 100644 --- a/src/api/extraction/presentation/models.py +++ b/src/api/extraction/presentation/models.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field from extraction.domain.entities.agent_session import ExtractionAgentSession -from extraction.domain.value_objects import ExtractionSessionMode +from extraction.domain.value_objects import BootstrapIntakePath, ExtractionSessionMode class ExtractionSessionResponse(BaseModel): @@ -45,3 +45,13 @@ class ExtractionSessionListResponse(BaseModel): sessions: list[ExtractionSessionResponse] count: int + +class BootstrapIntakePathSelectionRequest(BaseModel): + """Request model for bootstrap intake path selection.""" + + selected_path: BootstrapIntakePath + capabilities_goals: str | None = Field( + default=None, + description="Optional user summary of capabilities and schema goals", + ) + diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py index af6bd2e99..1301cd224 100644 --- a/src/api/extraction/presentation/routes.py +++ b/src/api/extraction/presentation/routes.py @@ -10,6 +10,7 @@ from extraction.dependencies import get_extraction_agent_session_service from extraction.domain.value_objects import ExtractionSessionMode from extraction.presentation.models import ( + BootstrapIntakePathSelectionRequest, ExtractionSessionListResponse, ExtractionSessionResponse, ) @@ -121,3 +122,30 @@ async def clear_chat( ) return ExtractionSessionResponse.from_domain(session) + +@router.post( + "/knowledge-graphs/{knowledge_graph_id}/sessions/schema_bootstrap/active/intake-path", + response_model=ExtractionSessionResponse, +) +async def select_bootstrap_intake_path( + knowledge_graph_id: str, + request: BootstrapIntakePathSelectionRequest, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ + ExtractionAgentSessionService, Depends(get_extraction_agent_session_service) + ], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> ExtractionSessionResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + session = await service.set_bootstrap_intake_path_for_active_session( + user_id=current_user.user_id.value, + knowledge_graph_id=knowledge_graph_id, + selected_path=request.selected_path, + capabilities_goals=request.capabilities_goals, + ) + return ExtractionSessionResponse.from_domain(session) + diff --git a/src/api/tests/unit/extraction/application/test_agent_session_service.py b/src/api/tests/unit/extraction/application/test_agent_session_service.py index 5d62f9ba5..444ab8c18 100644 --- a/src/api/tests/unit/extraction/application/test_agent_session_service.py +++ b/src/api/tests/unit/extraction/application/test_agent_session_service.py @@ -9,7 +9,7 @@ from extraction.application.agent_session_service import ExtractionAgentSessionService from extraction.domain.entities.agent_session import ExtractionAgentSession -from extraction.domain.value_objects import ExtractionSessionMode +from extraction.domain.value_objects import BootstrapIntakePath, ExtractionSessionMode class _InMemoryAgentSessionRepository: @@ -212,3 +212,42 @@ async def test_new_session_initializes_runtime_context_from_skill_resolution(sel assert config["skills"]["schema_modeling"] == "bootstrap schema guidance" assert skill_resolution.calls == [("kg-1", ExtractionSessionMode.SCHEMA_BOOTSTRAP)] + async def test_bootstrap_session_seeds_capabilities_intake_prompt_state(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + + session = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + + assert session.message_history + assert session.message_history[0]["role"] == "assistant" + assert "capabilities" in session.message_history[0]["content"].lower() + intake = session.runtime_context["bootstrap_intake"] + assert intake["status"] == "awaiting_path_selection" + assert intake["selected_path"] is None + + async def test_select_bootstrap_intake_path_persists_choice_for_continuity(self): + repo = _InMemoryAgentSessionRepository() + service = ExtractionAgentSessionService(repository=repo) + session = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + + updated = await service.set_bootstrap_intake_path_for_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + selected_path=BootstrapIntakePath.GUIDED_CO_DESIGN, + capabilities_goals="I can provide domain terms but need guidance.", + ) + + intake = updated.runtime_context["bootstrap_intake"] + assert intake["selected_path"] == BootstrapIntakePath.GUIDED_CO_DESIGN.value + assert intake["status"] == "path_selected" + assert intake["capabilities_goals"] == "I can provide domain terms but need guidance." + assert updated.id == session.id + diff --git a/src/api/tests/unit/extraction/presentation/test_routes.py b/src/api/tests/unit/extraction/presentation/test_routes.py index a59923026..96a5d1df3 100644 --- a/src/api/tests/unit/extraction/presentation/test_routes.py +++ b/src/api/tests/unit/extraction/presentation/test_routes.py @@ -9,6 +9,7 @@ from extraction.application.agent_session_service import ExtractionAgentSessionService from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import BootstrapIntakePath from iam.application.value_objects import CurrentUser from iam.domain.value_objects import TenantId, UserId @@ -176,3 +177,23 @@ def test_active_session_endpoint_returns_existing_active_session( assert second.status_code == status.HTTP_200_OK assert first.json()["id"] == second.json()["id"] + def test_select_bootstrap_intake_path_persists_choice(self, extraction_client): + client, _ = extraction_client + active = client.get( + "/extraction/knowledge-graphs/kg-123/sessions/schema_bootstrap/active" + ) + assert active.status_code == status.HTTP_200_OK + + response = client.post( + "/extraction/knowledge-graphs/kg-123/sessions/schema_bootstrap/active/intake-path", + json={ + "selected_path": BootstrapIntakePath.GUIDED_CO_DESIGN.value, + "capabilities_goals": "I know core entities but need help with relationships.", + }, + ) + assert response.status_code == status.HTTP_200_OK + payload = response.json() + intake = payload["runtime_context"]["bootstrap_intake"] + assert intake["selected_path"] == BootstrapIntakePath.GUIDED_CO_DESIGN.value + assert intake["status"] == "path_selected" + From b11d5597db1381641dcc032faa26e3a8e4d2655f Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:20:07 -0400 Subject: [PATCH 22/80] feat(extraction): package workload runtime context and skills mounts (#679) (#699) Build a filesystem runtime context for extraction workloads by materializing ingestion package resources, reconstructing repository files, and exposing a deterministic skills directory path; wire it through extraction event handling and local/deployed container configuration. Co-authored-by: Cursor --- compose.yaml | 2 + .../apps/kartograph/base/api-deployment.yaml | 6 ++ src/api/extraction/infrastructure/__init__.py | 4 + .../infrastructure/event_handler.py | 7 ++ .../infrastructure/runtime_context_builder.py | 52 +++++++++++++ src/api/extraction/ports/services.py | 14 ++++ src/api/main.py | 13 ++++ .../test_extraction_event_handler.py | 25 +++++++ .../test_runtime_context_builder.py | 74 +++++++++++++++++++ 9 files changed, 197 insertions(+) create mode 100644 src/api/extraction/infrastructure/runtime_context_builder.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_runtime_context_builder.py diff --git a/compose.yaml b/compose.yaml index 9e2d1d838..bf632a8a7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -145,6 +145,7 @@ services: - kartograph volumes: - ./certs:/certs:ro + - ./skills:/app/skills:ro # Mount host CA bundle (supports multiple OS types via env var) # Default fallback order: RHEL/Fedora -> Debian/Ubuntu -> macOS - ${HOST_CA_BUNDLE:-/etc/pki/tls/certs/ca-bundle.crt}:/etc/ssl/certs/ca-bundle.crt:ro @@ -156,6 +157,7 @@ services: - GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=/certs/spicedb-cert.pem # SSL cert file uses mounted path (same for all systems) - SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt + - KARTOGRAPH_EXTRACTION_SKILLS_DIR=/app/skills depends_on: postgres: condition: service_healthy diff --git a/deploy/apps/kartograph/base/api-deployment.yaml b/deploy/apps/kartograph/base/api-deployment.yaml index 1de0bc5ee..3c9f2f193 100644 --- a/deploy/apps/kartograph/base/api-deployment.yaml +++ b/deploy/apps/kartograph/base/api-deployment.yaml @@ -155,11 +155,15 @@ spec: secretKeyRef: name: kartograph-sso-client-swagger-docs key: client_id + - name: KARTOGRAPH_EXTRACTION_SKILLS_DIR + value: /app/skills volumeMounts: - name: spicedb-ca mountPath: /etc/spicedb-ca readOnly: true + - name: extraction-skills + mountPath: /app/skills livenessProbe: httpGet: path: /health @@ -190,3 +194,5 @@ spec: items: - key: service-ca.crt path: service-ca.crt + - name: extraction-skills + emptyDir: {} diff --git a/src/api/extraction/infrastructure/__init__.py b/src/api/extraction/infrastructure/__init__.py index 3ffd68fe2..20d48d352 100644 --- a/src/api/extraction/infrastructure/__init__.py +++ b/src/api/extraction/infrastructure/__init__.py @@ -5,10 +5,14 @@ ExtractionAgentSessionRepository, ExtractionSkillOverrideRepository, ) +from extraction.infrastructure.runtime_context_builder import ( + FilesystemExtractionRuntimeContextBuilder, +) __all__ = [ "ExtractionEventHandler", "ExtractionAgentSessionRepository", "ExtractionSkillOverrideRepository", + "FilesystemExtractionRuntimeContextBuilder", ] diff --git a/src/api/extraction/infrastructure/event_handler.py b/src/api/extraction/infrastructure/event_handler.py index 4eb5fa33c..32cceb1ee 100644 --- a/src/api/extraction/infrastructure/event_handler.py +++ b/src/api/extraction/infrastructure/event_handler.py @@ -40,6 +40,7 @@ def __init__( self, extraction_service: IExtractionService, outbox: "IOutboxRepository", + runtime_context_builder: Any, ) -> None: """Initialize the extraction event handler. @@ -50,6 +51,7 @@ def __init__( """ self._extraction_service = extraction_service self._outbox = outbox + self._runtime_context_builder = runtime_context_builder def supported_event_types(self) -> frozenset[str]: """Return event types handled by this handler.""" @@ -80,11 +82,16 @@ async def handle( now = datetime.now(UTC) try: + runtime_context = self._runtime_context_builder.build( + sync_run_id=sync_run_id, + job_package_id=job_package_id, + ) mutation_log_id = await self._extraction_service.run( sync_run_id=sync_run_id, data_source_id=data_source_id, knowledge_graph_id=knowledge_graph_id, job_package_id=job_package_id, + runtime_context=runtime_context, ) except Exception as exc: await self._outbox.append( diff --git a/src/api/extraction/infrastructure/runtime_context_builder.py b/src/api/extraction/infrastructure/runtime_context_builder.py new file mode 100644 index 000000000..9c349f12b --- /dev/null +++ b/src/api/extraction/infrastructure/runtime_context_builder.py @@ -0,0 +1,52 @@ +"""Filesystem runtime context preparation for extraction workloads.""" + +from __future__ import annotations + +from pathlib import Path +import zipfile + +from extraction.ports.services import ExtractionRuntimeContext +from shared_kernel.job_package.path_safety import validate_zip_entry_name +from shared_kernel.job_package.reader import JobPackageReader +from shared_kernel.job_package.value_objects import JobPackageId + + +class FilesystemExtractionRuntimeContextBuilder: + """Prepare runtime directories from JobPackage + skills mount path.""" + + def __init__(self, *, work_dir: Path, skills_dir: Path) -> None: + self._work_dir = work_dir + self._skills_dir = skills_dir + + def build(self, *, sync_run_id: str, job_package_id: str) -> ExtractionRuntimeContext: + package_id = JobPackageId(value=job_package_id) + archive_path = self._work_dir / package_id.archive_name() + reader = JobPackageReader(archive_path) + + run_root = self._work_dir / "extraction-runtimes" / sync_run_id + ingestion_context_dir = run_root / "ingestion-context" + repository_files_dir = run_root / "repository-files" + ingestion_context_dir.mkdir(parents=True, exist_ok=True) + repository_files_dir.mkdir(parents=True, exist_ok=True) + self._skills_dir.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(archive_path) as archive: + for entry_name in archive.namelist(): + validate_zip_entry_name(entry_name) + archive.extract(entry_name, path=ingestion_context_dir) + + # Materialize repository-style files for agent-friendly traversal. + for change in reader.iter_changeset(): + if change.content_ref is None or not change.path: + continue + validate_zip_entry_name(change.path) + output_path = repository_files_dir / change.path + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(reader.read_content(change.content_ref)) + + return ExtractionRuntimeContext( + ingestion_context_dir=str(ingestion_context_dir), + repository_files_dir=str(repository_files_dir), + skills_dir=str(self._skills_dir), + job_package_archive=str(archive_path), + ) diff --git a/src/api/extraction/ports/services.py b/src/api/extraction/ports/services.py index f4a62c655..7c73d4865 100644 --- a/src/api/extraction/ports/services.py +++ b/src/api/extraction/ports/services.py @@ -2,9 +2,20 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Protocol +@dataclass(frozen=True) +class ExtractionRuntimeContext: + """Resolved runtime paths available to extraction workloads.""" + + ingestion_context_dir: str + repository_files_dir: str + skills_dir: str + job_package_archive: str + + class IExtractionService(Protocol): """Protocol for the AI-based entity extraction service. @@ -22,6 +33,7 @@ async def run( data_source_id: str, knowledge_graph_id: str, job_package_id: str, + runtime_context: ExtractionRuntimeContext, ) -> str: """Run the AI extraction pipeline for a JobPackage. @@ -30,6 +42,8 @@ async def run( data_source_id: Identifier for the data source being extracted knowledge_graph_id: Identifier for the target knowledge graph job_package_id: Identifier for the JobPackage to process + runtime_context: Resolved runtime context paths for ingestion resources, + reconstructed repository files, and skills availability. Returns: mutation_log_id: Identifier for the produced MutationLog (JSONL) diff --git a/src/api/main.py b/src/api/main.py index 2061f1808..9204c77de 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -2,6 +2,7 @@ import asyncio from contextlib import asynccontextmanager +import os from pathlib import Path from typing import Any from urllib.parse import urlparse @@ -56,6 +57,9 @@ # Default work directory for JobPackage ZIP archives _JOB_PACKAGE_WORK_DIR = Path("/tmp/kartograph/job_packages") # noqa: S108 +_EXTRACTION_SKILLS_DIR = Path( + os.getenv("KARTOGRAPH_EXTRACTION_SKILLS_DIR", "/app/skills") +) # Scheduler polling interval (seconds) _SCHEDULER_POLL_INTERVAL_SECONDS = 60 @@ -305,6 +309,7 @@ async def run( data_source_id: str, knowledge_graph_id: str, job_package_id: str, + runtime_context: Any, ) -> str: raise NotImplementedError( "AI extraction pipeline is not yet implemented. " @@ -332,12 +337,20 @@ def supported_event_types(self) -> frozenset[str]: async def handle(self, event_type: str, payload: dict[str, Any]) -> None: from infrastructure.outbox.repository import OutboxRepository from extraction.infrastructure.event_handler import ExtractionEventHandler + from extraction.infrastructure.runtime_context_builder import ( + FilesystemExtractionRuntimeContextBuilder, + ) async with self._session_factory() as session: outbox = OutboxRepository(session=session) + runtime_context_builder = FilesystemExtractionRuntimeContextBuilder( + work_dir=_JOB_PACKAGE_WORK_DIR, + skills_dir=_EXTRACTION_SKILLS_DIR, + ) extraction_handler = ExtractionEventHandler( extraction_service=self._extraction_service, outbox=outbox, + runtime_context_builder=runtime_context_builder, ) await extraction_handler.handle(event_type, payload) await session.commit() diff --git a/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py b/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py index 38738b321..c04a92096 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py +++ b/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py @@ -18,6 +18,7 @@ import pytest from extraction.infrastructure.event_handler import ExtractionEventHandler +from extraction.ports.services import ExtractionRuntimeContext class _FakeOutboxRepository: @@ -65,6 +66,7 @@ async def run( data_source_id: str, knowledge_graph_id: str, job_package_id: str, + runtime_context: ExtractionRuntimeContext, ) -> str: self.calls.append( { @@ -72,6 +74,7 @@ async def run( "data_source_id": data_source_id, "knowledge_graph_id": knowledge_graph_id, "job_package_id": job_package_id, + "runtime_context": runtime_context, } ) if self._fail: @@ -79,6 +82,22 @@ async def run( return "mutation-log-001" +class _FakeRuntimeContextBuilder: + def __init__(self) -> None: + self.calls: list[dict[str, str]] = [] + + def build(self, *, sync_run_id: str, job_package_id: str) -> ExtractionRuntimeContext: + self.calls.append( + {"sync_run_id": sync_run_id, "job_package_id": job_package_id} + ) + return ExtractionRuntimeContext( + ingestion_context_dir="/tmp/ingestion-context", + repository_files_dir="/tmp/repository-files", + skills_dir="/app/skills", + job_package_archive="/tmp/job-package.zip", + ) + + @pytest.fixture def outbox() -> _FakeOutboxRepository: return _FakeOutboxRepository() @@ -99,9 +118,11 @@ def handler( extraction_service: _FakeExtractionService, outbox: _FakeOutboxRepository, ) -> ExtractionEventHandler: + runtime_context_builder = _FakeRuntimeContextBuilder() return ExtractionEventHandler( extraction_service=extraction_service, outbox=outbox, + runtime_context_builder=runtime_context_builder, ) @@ -151,6 +172,7 @@ async def test_runs_extraction_on_job_package_produced( assert call["job_package_id"] == "pkg-001" assert call["data_source_id"] == "ds-001" assert call["knowledge_graph_id"] == "kg-001" + assert call["runtime_context"].skills_dir == "/app/skills" async def test_emits_mutation_log_produced_on_success( self, @@ -208,6 +230,7 @@ async def test_emits_extraction_failed_on_service_error( handler = ExtractionEventHandler( extraction_service=failing_service, outbox=outbox, + runtime_context_builder=_FakeRuntimeContextBuilder(), ) payload = _job_package_produced_payload(sync_run_id="run-002") await handler.handle("JobPackageProduced", payload) @@ -228,6 +251,7 @@ async def test_extraction_failed_aggregate_type( handler = ExtractionEventHandler( extraction_service=failing_service, outbox=outbox, + runtime_context_builder=_FakeRuntimeContextBuilder(), ) await handler.handle( "JobPackageProduced", @@ -285,6 +309,7 @@ async def test_outbox_failure_after_successful_extraction_propagates( handler = ExtractionEventHandler( extraction_service=extraction_service, outbox=failing_outbox, + runtime_context_builder=_FakeRuntimeContextBuilder(), ) with pytest.raises(RuntimeError, match="outbox write failed"): diff --git a/src/api/tests/unit/extraction/infrastructure/test_runtime_context_builder.py b/src/api/tests/unit/extraction/infrastructure/test_runtime_context_builder.py new file mode 100644 index 000000000..9e5bf93a5 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_runtime_context_builder.py @@ -0,0 +1,74 @@ +"""Unit tests for filesystem extraction runtime context builder.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from extraction.infrastructure.runtime_context_builder import ( + FilesystemExtractionRuntimeContextBuilder, +) +from shared_kernel.job_package.builder import JobPackageBuilder +from shared_kernel.job_package.value_objects import ( + AdapterCheckpoint, + ChangeOperation, + ChangesetEntry, + ContentRef, + SyncMode, +) + + +def _build_job_package(archive_dir: Path) -> str: + content_bytes = b"print('hello runtime context')\n" + content_ref = ContentRef.from_bytes(content_bytes) + changeset_entry = ChangesetEntry( + operation=ChangeOperation.ADD, + id="file-1", + type="io.kartograph.change.file", + path="src/main.py", + content_ref=content_ref, + content_type="text/x-python", + metadata={}, + ) + builder = JobPackageBuilder( + data_source_id="ds-123", + knowledge_graph_id="kg-123", + sync_mode=SyncMode.FULL_REFRESH, + ) + ref = builder.add_content(content_bytes) + builder.add_changeset_entry( + ChangesetEntry( + operation=ChangeOperation.ADD, + id=changeset_entry.id, + type=changeset_entry.type, + path=changeset_entry.path, + content_ref=ref, + content_type=changeset_entry.content_type, + metadata=changeset_entry.metadata, + ) + ) + builder.set_checkpoint(AdapterCheckpoint(schema_version="1.0.0", data={})) + archive_path = builder.build(archive_dir) + return archive_path.stem.removeprefix("job-package-") + + +@pytest.mark.asyncio +async def test_build_materializes_ingestion_context_and_repository_files(tmp_path: Path): + work_dir = tmp_path / "work" + work_dir.mkdir(parents=True, exist_ok=True) + package_id = _build_job_package(work_dir) + skills_dir = tmp_path / "skills" + + builder = FilesystemExtractionRuntimeContextBuilder( + work_dir=work_dir, + skills_dir=skills_dir, + ) + runtime = builder.build(sync_run_id="run-123", job_package_id=package_id) + + assert Path(runtime.ingestion_context_dir).exists() + assert Path(runtime.repository_files_dir, "src/main.py").exists() + assert Path(runtime.repository_files_dir, "src/main.py").read_text() == ( + "print('hello runtime context')\n" + ) + assert Path(runtime.skills_dir).exists() From faca1c15c1d6a8f96e5bae4642ea7aff0b199c72 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:31:42 -0400 Subject: [PATCH 23/80] feat(dev-ui): enrich schema inspector with type metadata badges (#670) (#700) Enhance schema browser rows to display prepopulated type indicators and live per-type instance counts with lazy query-backed loading, while extending shared type contracts and tests to cover the new inspector metadata behavior. Co-authored-by: Cursor --- src/dev-ui/app/pages/graph/schema.vue | 76 +++++++++++++++++++++ src/dev-ui/app/tests/schema-browser.test.ts | 10 +++ src/dev-ui/app/types/index.ts | 2 + 3 files changed, 88 insertions(+) diff --git a/src/dev-ui/app/pages/graph/schema.vue b/src/dev-ui/app/pages/graph/schema.vue index cd032bce3..4ed6ace26 100644 --- a/src/dev-ui/app/pages/graph/schema.vue +++ b/src/dev-ui/app/pages/graph/schema.vue @@ -22,6 +22,7 @@ import { CopyableText } from '@/components/ui/copyable-text' import type { TypeDefinition } from '~/types' const { listNodeLabels, listEdgeLabels, getNodeSchema, getEdgeSchema } = useGraphApi() +const { queryGraph } = useQueryApi() const { extractErrorMessage } = useErrorHandler() const { hasTenant, tenantVersion } = useTenant() @@ -47,6 +48,8 @@ const searchInputRef = ref | null>(null) const expandedLabels = reactive(new Set()) const schemaCache = reactive(new Map()) const schemaLoadingLabels = reactive(new Set()) +const instanceCountCache = reactive(new Map()) +const instanceCountLoadingKeys = reactive(new Set()) // Virtual scroll container refs (virtualizers defined after filtered computeds) const nodeScrollRef = ref(null) @@ -146,6 +149,10 @@ function toggleExpand(label: string, entityType: 'node' | 'edge') { if (!schemaCache.has(label)) { fetchLabelSchema(label, entityType) } + const cacheKey = `${entityType}:${label}` + if (!instanceCountCache.has(cacheKey)) { + fetchInstanceCount(label, entityType) + } } // Force re-measurement after DOM update for variable-height rows nextTick(() => { @@ -176,6 +183,36 @@ async function fetchLabelSchema(label: string, entityType: 'node' | 'edge') { } } +function getInstanceCount(label: string, entityType: 'node' | 'edge'): number | null { + const cacheKey = `${entityType}:${label}` + return instanceCountCache.has(cacheKey) ? (instanceCountCache.get(cacheKey) ?? 0) : null +} + +function isInstanceCountLoading(label: string, entityType: 'node' | 'edge'): boolean { + return instanceCountLoadingKeys.has(`${entityType}:${label}`) +} + +async function fetchInstanceCount(label: string, entityType: 'node' | 'edge') { + const cacheKey = `${entityType}:${label}` + instanceCountLoadingKeys.add(cacheKey) + try { + const cypher = entityType === 'node' + ? `MATCH (n:\`${label}\`) RETURN count(n) AS instance_count` + : `MATCH ()-[r:\`${label}\`]->() RETURN count(r) AS instance_count` + const result = await queryGraph(cypher, 10, 1) + const value = result.rows[0]?.instance_count + const parsed = typeof value === 'number' + ? value + : Number.parseInt(String(value ?? '0'), 10) + instanceCountCache.set(cacheKey, Number.isFinite(parsed) ? parsed : 0) + } catch { + // Avoid noisy toasts from metadata enrichment failures. + instanceCountCache.set(cacheKey, 0) + } finally { + instanceCountLoadingKeys.delete(cacheKey) + } +} + // ── Cross-page Navigation ────────────────────────────────────────────────── function navigateToQuery(label: string, entityType: 'node' | 'edge') { @@ -247,6 +284,8 @@ watch(tenantVersion, () => { searchQuery.value = '' expandedLabels.clear() schemaCache.clear() + instanceCountCache.clear() + instanceCountLoadingKeys.clear() fetchNodeLabels() fetchEdgeLabels() } @@ -392,6 +431,28 @@ onUnmounted(() => { > Node + + Prepopulated + + + + Counting... + + + {{ getInstanceCount(filteredNodeLabels[virtualRow.index], 'node') }} instances + @@ -587,6 +648,21 @@ onUnmounted(() => { > Edge + + + Counting... + + + {{ getInstanceCount(filteredEdgeLabels[virtualRow.index], 'edge') }} instances + diff --git a/src/dev-ui/app/tests/schema-browser.test.ts b/src/dev-ui/app/tests/schema-browser.test.ts index 1532101b8..cde8d8323 100644 --- a/src/dev-ui/app/tests/schema-browser.test.ts +++ b/src/dev-ui/app/tests/schema-browser.test.ts @@ -345,6 +345,16 @@ describe('Schema Browser - schema.vue uses ontology editor (not mutations consol it('graph explorer button (second) still navigates to /graph/explorer', () => { expect(schemaContent).toContain("path: '/graph/explorer'") }) + + it('schema.vue shows prepopulated indicator badge when provided', () => { + expect(schemaContent).toContain('Prepopulated') + expect(schemaContent).toContain('?.prepopulated') + }) + + it('schema.vue shows instance count metadata badge for node and edge types', () => { + expect(schemaContent).toContain('instances') + expect(schemaContent).toContain('fetchInstanceCount') + }) }) // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/dev-ui/app/types/index.ts b/src/dev-ui/app/types/index.ts index 6462452a2..aa2081e04 100644 --- a/src/dev-ui/app/types/index.ts +++ b/src/dev-ui/app/types/index.ts @@ -103,6 +103,8 @@ export interface TypeDefinition { description: string required_properties: string[] optional_properties: string[] + prepopulated?: boolean + instance_count?: number } export interface SchemaLabelsResponse { From 6b6d32e91e1de8a7075d7e858580e966bc455b3f Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:37:28 -0400 Subject: [PATCH 24/80] feat(management): add extraction run-control APIs for sync lifecycle (#671) (#701) Add manage-authorized run-control operations (start, pause, halt, reset_running, reset_failed, reset_completed, reset_all) over data source sync runs, expose them via dedicated management routes, and verify behavior with unit tests for both service transitions and HTTP contract responses. Co-authored-by: Cursor --- .../services/data_source_service.py | 100 ++++++++++++++++ .../presentation/data_sources/models.py | 21 ++++ .../presentation/data_sources/routes.py | 49 ++++++++ .../application/test_data_source_service.py | 112 ++++++++++++++++++ .../presentation/test_data_sources_routes.py | 72 +++++++++++ 5 files changed, 354 insertions(+) diff --git a/src/api/management/application/services/data_source_service.py b/src/api/management/application/services/data_source_service.py index d17b4f7ff..b128052fc 100644 --- a/src/api/management/application/services/data_source_service.py +++ b/src/api/management/application/services/data_source_service.py @@ -49,6 +49,16 @@ class DataSourceWithLatestRun: latest_sync_run: DataSourceSyncRun | None +@dataclass +class RunControlResult: + """Result payload for extraction run-control commands.""" + + action: str + affected_count: int + updated_runs: list[DataSourceSyncRun] + started_run: DataSourceSyncRun | None = None + + class DataSourceService: """Application service for data source management. @@ -655,3 +665,93 @@ async def trigger_sync( self._probe.sync_requested(ds_id=ds_id) return sync_run + + async def apply_run_control( + self, + user_id: str, + ds_id: str, + action: str, + ) -> RunControlResult: + """Apply run-control action to sync runs for a data source.""" + if action == "start": + started = await self.trigger_sync(user_id=user_id, ds_id=ds_id) + return RunControlResult( + action=action, + affected_count=1, + updated_runs=[], + started_run=started, + ) + + has_manage = await self._check_permission( + user_id=user_id, + resource_type=ResourceType.DATA_SOURCE, + resource_id=ds_id, + permission=Permission.MANAGE, + ) + if not has_manage: + self._probe.permission_denied( + user_id=user_id, + resource_id=ds_id, + permission=Permission.MANAGE, + ) + raise UnauthorizedError( + f"User {user_id} lacks manage permission on data source {ds_id}" + ) + + ds = await self._ds_repo.get_by_id(DataSourceId(value=ds_id)) + if ds is None or ds.tenant_id != self._scope_to_tenant: + raise ValueError(f"Data source {ds_id} not found") + + runs = await self._sync_run_repo.find_by_data_source(ds_id) + active_statuses = {"pending", "ingesting", "ai_extracting", "applying"} + targets: list[DataSourceSyncRun] = [] + now = datetime.now(UTC) + + if action == "pause": + targets = [run for run in runs if run.status in active_statuses] + for run in targets: + run.status = "pending" + run.logs.append("Run paused by control plane") + elif action == "halt": + targets = [run for run in runs if run.status in active_statuses] + for run in targets: + run.status = "failed" + run.completed_at = now + run.error = "Run halted by control plane" + run.logs.append("Run halted by control plane") + elif action == "reset_running": + targets = [run for run in runs if run.status in active_statuses] + for run in targets: + run.status = "pending" + run.completed_at = None + run.error = None + elif action == "reset_failed": + targets = [run for run in runs if run.status == "failed"] + for run in targets: + run.status = "pending" + run.completed_at = None + run.error = None + elif action == "reset_completed": + targets = [run for run in runs if run.status == "completed"] + for run in targets: + run.status = "pending" + run.completed_at = None + run.error = None + elif action == "reset_all": + targets = list(runs) + for run in targets: + run.status = "pending" + run.completed_at = None + run.error = None + else: + raise ValueError(f"Unsupported run control action: {action}") + + for run in targets: + await self._sync_run_repo.save(run) + await self._session.commit() + + return RunControlResult( + action=action, + affected_count=len(targets), + updated_runs=targets, + ) diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index c9c385811..a1115daaf 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from typing import Literal from pydantic import BaseModel, Field @@ -320,6 +321,26 @@ def from_domain(cls, run: DataSourceSyncRun) -> SyncRunResponse: ) +RunControlAction = Literal[ + "start", + "pause", + "halt", + "reset_running", + "reset_failed", + "reset_completed", + "reset_all", +] + + +class RunControlResponse(BaseModel): + """Response model for run-control actions.""" + + action: RunControlAction + affected_count: int + updated_runs: list[SyncRunResponse] = Field(default_factory=list) + started_run: SyncRunResponse | None = None + + class DataSourceWithSyncResponse(BaseModel): """Data source response with embedded latest sync run. diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index 99c99e419..c35ab68c7 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -27,6 +27,8 @@ DataSourceListResponse, DataSourceResponse, DataSourceWithSyncResponse, + RunControlAction, + RunControlResponse, SyncRunLogsResponse, SyncRunResponse, UpdateDataSourceRequest, @@ -442,6 +444,53 @@ async def list_sync_runs( ) +@router.post( + "/data-sources/{ds_id}/run-controls/{action}", + status_code=status.HTTP_200_OK, +) +async def control_sync_runs( + ds_id: str, + action: RunControlAction, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[DataSourceService, Depends(get_data_source_service)], +) -> RunControlResponse: + """Apply run-control action to extraction sync runs for a data source.""" + try: + result = await service.apply_run_control( + user_id=current_user.user_id.value, + ds_id=ds_id, + action=action, + ) + return RunControlResponse( + action=action, + affected_count=result.affected_count, + updated_runs=[SyncRunResponse.from_domain(run) for run in result.updated_runs], + started_run=( + SyncRunResponse.from_domain(result.started_run) + if result.started_run is not None + else None + ), + ) + except UnauthorizedError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action", + ) + except ValueError as e: + detail = str(e) + status_code = ( + status.HTTP_422_UNPROCESSABLE_ENTITY + if "Unsupported run control action" in detail + else status.HTTP_404_NOT_FOUND + ) + raise HTTPException(status_code=status_code, detail=detail) + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to apply run control action", + ) + + @router.patch( "/data-sources/{ds_id}", response_model=DataSourceResponse, diff --git a/src/api/tests/unit/management/application/test_data_source_service.py b/src/api/tests/unit/management/application/test_data_source_service.py index 3ebdc3220..973528875 100644 --- a/src/api/tests/unit/management/application/test_data_source_service.py +++ b/src/api/tests/unit/management/application/test_data_source_service.py @@ -459,6 +459,25 @@ def _make_ds( return ds +def _make_sync_run( + *, + run_id: str, + data_source_id: str, + status: str, +) -> DataSourceSyncRun: + now = datetime.now(UTC) + return DataSourceSyncRun( + id=run_id, + data_source_id=data_source_id, + status=status, + started_at=now, + completed_at=None, + error=None, + created_at=now, + logs=[], + ) + + # ---- create ---- @@ -1030,6 +1049,99 @@ async def test_trigger_sync_creates_sync_run_and_saves_ds( assert ds_probe.sync_requested_calls[0]["ds_id"] == ds.id.value +class TestDataSourceServiceRunControls: + """Tests for extraction run-control operations.""" + + @pytest.mark.asyncio + async def test_pause_updates_active_runs_to_pending( + self, service, authz, ds_repo, sync_run_repo, user_id + ) -> None: + ds = _make_ds() + authz.grant_all() + ds_repo.seed(ds) + sync_run_repo.seed( + _make_sync_run(run_id="run-1", data_source_id=ds.id.value, status="ingesting"), + _make_sync_run(run_id="run-2", data_source_id=ds.id.value, status="applying"), + _make_sync_run(run_id="run-3", data_source_id=ds.id.value, status="completed"), + ) + + result = await service.apply_run_control( + user_id=user_id, + ds_id=ds.id.value, + action="pause", + ) + + assert result.affected_count == 2 + assert all(run.status == "pending" for run in result.updated_runs) + + @pytest.mark.asyncio + async def test_halt_marks_active_runs_as_failed( + self, service, authz, ds_repo, sync_run_repo, user_id + ) -> None: + ds = _make_ds() + authz.grant_all() + ds_repo.seed(ds) + sync_run_repo.seed( + _make_sync_run( + run_id="run-1", data_source_id=ds.id.value, status="ai_extracting" + ) + ) + + result = await service.apply_run_control( + user_id=user_id, + ds_id=ds.id.value, + action="halt", + ) + + assert result.affected_count == 1 + halted = result.updated_runs[0] + assert halted.status == "failed" + assert halted.completed_at is not None + assert halted.error is not None + + @pytest.mark.asyncio + async def test_reset_failed_moves_failed_runs_to_pending( + self, service, authz, ds_repo, sync_run_repo, user_id + ) -> None: + ds = _make_ds() + authz.grant_all() + ds_repo.seed(ds) + failed = _make_sync_run(run_id="run-1", data_source_id=ds.id.value, status="failed") + failed.error = "old error" + failed.completed_at = datetime.now(UTC) + sync_run_repo.seed(failed) + + result = await service.apply_run_control( + user_id=user_id, + ds_id=ds.id.value, + action="reset_failed", + ) + + assert result.affected_count == 1 + updated = result.updated_runs[0] + assert updated.status == "pending" + assert updated.error is None + assert updated.completed_at is None + + @pytest.mark.asyncio + async def test_start_action_creates_new_sync_run( + self, service, authz, ds_repo, user_id + ) -> None: + ds = _make_ds() + authz.grant_all() + ds_repo.seed(ds) + + result = await service.apply_run_control( + user_id=user_id, + ds_id=ds.id.value, + action="start", + ) + + assert result.started_run is not None + assert result.started_run.status == "pending" + assert result.affected_count == 1 + + class TestDataSourceServiceCommitReferenceActions: """Tests for commit reference refresh/baseline actions.""" diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 2e64d01bf..3eaee1574 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -466,6 +466,78 @@ def test_list_sync_runs_returns_404_when_ds_not_found( mock_sync_run_repo.find_by_data_source.assert_not_called() +class TestRunControlRoutes: + """Tests for POST /management/data-sources/{ds_id}/run-controls/{action}.""" + + def test_pause_run_control_returns_200_with_affected_count( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + sample_sync_run: DataSourceSyncRun, + ) -> None: + mock_ds_service.apply_run_control.return_value = type( + "_Result", + (), + { + "action": "pause", + "affected_count": 1, + "updated_runs": [sample_sync_run], + "started_run": None, + }, + )() + + response = test_client.post( + "/management/data-sources/01JPQRST1234567890ABCDEFDS/run-controls/pause" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["action"] == "pause" + assert payload["affected_count"] == 1 + assert len(payload["updated_runs"]) == 1 + assert payload["started_run"] is None + + def test_start_run_control_returns_started_run( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + sample_sync_run: DataSourceSyncRun, + ) -> None: + mock_ds_service.apply_run_control.return_value = type( + "_Result", + (), + { + "action": "start", + "affected_count": 1, + "updated_runs": [], + "started_run": sample_sync_run, + }, + )() + + response = test_client.post( + "/management/data-sources/01JPQRST1234567890ABCDEFDS/run-controls/start" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["action"] == "start" + assert payload["affected_count"] == 1 + assert payload["started_run"]["id"] == sample_sync_run.id + + def test_run_control_returns_403_when_unauthorized( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + ) -> None: + mock_ds_service.apply_run_control.side_effect = UnauthorizedError("no permission") + + response = test_client.post( + "/management/data-sources/01JPQRST1234567890ABCDEFDS/run-controls/halt" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + class TestGetSyncRunLogsRoute: """Tests for GET /management/data-sources/{ds_id}/sync-runs/{run_id}/logs endpoint. From 9282149b98ef04f097d65359676318b8c0e8cbce Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 14:42:46 -0400 Subject: [PATCH 25/80] feat(dev-ui): add extraction telemetry dashboard metrics (#672) (#702) Expose sync-run token/cost metadata in management API responses and add an extraction telemetry dashboard in the data-sources workspace with active worker counts, status buckets, recent job events, and 24h cost trend indicators backed by auto-refreshing sync data. Co-authored-by: Cursor --- .../presentation/data_sources/models.py | 16 +++ .../presentation/test_data_sources_routes.py | 31 ++++ src/dev-ui/app/pages/data-sources/index.vue | 136 ++++++++++++++++++ src/dev-ui/app/tests/data-sources.test.ts | 24 ++++ 4 files changed, 207 insertions(+) diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index a1115daaf..e06a4bbd7 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -296,6 +296,12 @@ class SyncRunResponse(BaseModel): None, description="When the sync run completed" ) error: str | None = Field(None, description="Error message if the sync run failed") + token_usage_total: int | None = Field( + None, description="Total model tokens consumed during extraction for this run" + ) + cost_total_usd: float | None = Field( + None, description="Estimated USD cost for extraction execution in this run" + ) created_at: datetime = Field( ..., description="When the sync run record was created" ) @@ -317,6 +323,16 @@ def from_domain(cls, run: DataSourceSyncRun) -> SyncRunResponse: started_at=run.started_at, completed_at=run.completed_at, error=run.error, + token_usage_total=( + run.mutation_log_run.token_usage_total + if run.mutation_log_run is not None + else None + ), + cost_total_usd=( + run.mutation_log_run.cost_total_usd + if run.mutation_log_run is not None + else None + ), created_at=run.created_at, ) diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 3eaee1574..e8ee14fbb 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -18,6 +18,7 @@ from management.application.services.data_source_service import DataSourceService from management.domain.aggregates import DataSource from management.domain.entities import DataSourceSyncRun +from management.domain.entities.data_source_sync_run import MutationLogRunMetadata from management.infrastructure.git_diff_summary_service import DiffSummaryResult from management.domain.value_objects import ( DataSourceId, @@ -449,6 +450,36 @@ def test_list_sync_runs_returns_empty_list( assert response.status_code == status.HTTP_200_OK assert response.json() == [] + def test_list_sync_runs_includes_token_and_cost_metadata_when_available( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_sync_run_repo: AsyncMock, + sample_data_source: DataSource, + sample_sync_run: DataSourceSyncRun, + ) -> None: + """Sync run response should expose token/cost telemetry metadata.""" + sample_sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="mlog-1", + knowledge_graph_id=sample_data_source.knowledge_graph_id, + session_id="sess-1", + actor_id="actor-1", + started_at=sample_sync_run.started_at, + token_usage_total=3210, + cost_total_usd=1.23, + ) + mock_ds_service.get.return_value = sample_data_source + mock_sync_run_repo.find_by_data_source.return_value = [sample_sync_run] + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/sync-runs" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json()[0] + assert payload["token_usage_total"] == 3210 + assert payload["cost_total_usd"] == pytest.approx(1.23) + def test_list_sync_runs_returns_404_when_ds_not_found( self, test_client: TestClient, diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index f112f9ba9..09593dc8c 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -23,6 +23,10 @@ import { FileText, Settings, RefreshCw, + Cpu, + Coins, + DollarSign, + Clock3, } from 'lucide-vue-next' import { ADAPTERS, @@ -86,6 +90,8 @@ interface SyncRun { started_at: string completed_at: string | null error: string | null + token_usage_total?: number | null + cost_total_usd?: number | null created_at: string } @@ -728,6 +734,59 @@ const hasActiveSyncs = computed(() => }), ) +const telemetryRows = computed(() => + dataSources.value.flatMap((ds) => + (ds.sync_runs ?? []).map(run => ({ ...run, data_source_name: ds.name })), + ), +) + +const telemetryStatusBuckets = computed(() => { + const buckets = { + pending: 0, + ingesting: 0, + ai_extracting: 0, + applying: 0, + completed: 0, + failed: 0, + } + for (const row of telemetryRows.value) { + buckets[row.status] += 1 + } + return buckets +}) + +const telemetryRecentJobs = computed(() => + [...telemetryRows.value] + .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()) + .slice(0, 8), +) + +const telemetryActiveWorkers = computed(() => + telemetryRows.value.filter(row => ACTIVE_STATUSES.includes(row.status)).length, +) + +const telemetryTokenTotal = computed(() => + telemetryRows.value.reduce((sum, row) => sum + (row.token_usage_total ?? 0), 0), +) + +const telemetryCostTotal = computed(() => + telemetryRows.value.reduce((sum, row) => sum + (row.cost_total_usd ?? 0), 0), +) + +const telemetryCostTrend = computed(() => { + const now = Date.now() + const oneDayMs = 24 * 60 * 60 * 1000 + let current = 0 + let previous = 0 + for (const row of telemetryRows.value) { + const eventMs = new Date(row.completed_at ?? row.started_at).getTime() + if (eventMs >= now - oneDayMs) current += row.cost_total_usd ?? 0 + else if (eventMs >= now - oneDayMs * 2) previous += row.cost_total_usd ?? 0 + } + const delta = current - previous + return { current, previous, delta } +}) + /** Holds the active setInterval handle, or null when not polling. */ const pollInterval = ref | null>(null) @@ -1145,6 +1204,83 @@ async function handleDeleteDs() {
diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index d65f70779..1d5cb28ae 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -36,4 +36,23 @@ describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { expect(manageWorkspaceVue).toContain('active_schema_bootstrap_session_id') expect(manageWorkspaceVue).toContain('active_extraction_operations_session_id') }) + + it('keeps extraction conversation panel visible in extraction mode', () => { + expect(manageWorkspaceVue).toContain('Extraction Conversation') + expect(manageWorkspaceVue).toContain('message_history') + expect(manageWorkspaceVue).toContain('statusProjection.workspace_mode === \'extraction_operations\'') + }) + + it('supports explicit Clear chat reset for extraction session', () => { + expect(manageWorkspaceVue).toContain('clearChat') + expect(manageWorkspaceVue).toContain('/sessions/extraction_operations/clear-chat') + expect(manageWorkspaceVue).toContain('Clear chat') + }) + + it('provides tabbed lower operations area for extraction workflows', () => { + expect(manageWorkspaceVue).toContain('Operations Workspace') + expect(manageWorkspaceVue).toContain('TabsTrigger value="extraction-jobs"') + expect(manageWorkspaceVue).toContain('TabsTrigger value="manual-mutations"') + expect(manageWorkspaceVue).toContain('TabsTrigger value="run-logs"') + }) }) From 4afcea04d66ffff3a6017b5449197df739e358f7 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 15:59:32 -0400 Subject: [PATCH 31/80] feat(management): add KG-scoped mutation log browser data and UI (#708) Expose mutation-run identifiers and operation class counts on sync-run responses, then render a knowledge-graph-scoped MutationLog browser in the manage workspace with run summaries, per-entry previews, and token/cost metrics. Co-authored-by: Cursor --- .../presentation/data_sources/models.py | 33 ++++ .../presentation/test_data_sources_routes.py | 37 ++++ .../pages/knowledge-graphs/[kgId]/manage.vue | 185 +++++++++++++++++- .../knowledge-graph-manage-workspace.test.ts | 26 +++ 4 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index e06a4bbd7..b2508a79c 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -296,6 +296,19 @@ class SyncRunResponse(BaseModel): None, description="When the sync run completed" ) error: str | None = Field(None, description="Error message if the sync run failed") + mutation_log_id: str | None = Field( + None, description="Associated mutation log run ID when available" + ) + session_id: str | None = Field( + None, description="Extraction session ID associated with this mutation run" + ) + actor_id: str | None = Field( + None, description="Actor identity associated with this mutation run" + ) + operation_counts: dict[str, int] = Field( + default_factory=dict, + description="Operation counts grouped by operation class for this run", + ) token_usage_total: int | None = Field( None, description="Total model tokens consumed during extraction for this run" ) @@ -323,6 +336,26 @@ def from_domain(cls, run: DataSourceSyncRun) -> SyncRunResponse: started_at=run.started_at, completed_at=run.completed_at, error=run.error, + mutation_log_id=( + run.mutation_log_run.mutation_log_id + if run.mutation_log_run is not None + else None + ), + session_id=( + run.mutation_log_run.session_id + if run.mutation_log_run is not None + else None + ), + actor_id=( + run.mutation_log_run.actor_id + if run.mutation_log_run is not None + else None + ), + operation_counts=( + dict(run.mutation_log_run.operation_counts) + if run.mutation_log_run is not None + else {} + ), token_usage_total=( run.mutation_log_run.token_usage_total if run.mutation_log_run is not None diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index e8ee14fbb..62e1a9f53 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -480,6 +480,43 @@ def test_list_sync_runs_includes_token_and_cost_metadata_when_available( assert payload["token_usage_total"] == 3210 assert payload["cost_total_usd"] == pytest.approx(1.23) + def test_list_sync_runs_includes_mutation_log_run_preview_fields( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_sync_run_repo: AsyncMock, + sample_data_source: DataSource, + sample_sync_run: DataSourceSyncRun, + ) -> None: + """Sync run response should include mutation-run IDs and op class counts.""" + sample_sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="mlog-preview-1", + knowledge_graph_id=sample_data_source.knowledge_graph_id, + session_id="sess-preview-1", + actor_id="actor-preview-1", + started_at=sample_sync_run.started_at, + token_usage_total=144, + cost_total_usd=0.07, + operation_counts={"create_node": 8, "create_edge": 13, "update_node": 2}, + ) + mock_ds_service.get.return_value = sample_data_source + mock_sync_run_repo.find_by_data_source.return_value = [sample_sync_run] + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/sync-runs" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json()[0] + assert payload["mutation_log_id"] == "mlog-preview-1" + assert payload["session_id"] == "sess-preview-1" + assert payload["actor_id"] == "actor-preview-1" + assert payload["operation_counts"] == { + "create_node": 8, + "create_edge": 13, + "update_node": 2, + } + def test_list_sync_runs_returns_404_when_ds_not_found( self, test_client: TestClient, diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 9c47c726c..c0b2584d7 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -1,7 +1,7 @@ + + diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 46aced110..27119a4b7 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -6,8 +6,8 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' -import { Input } from '@/components/ui/input' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import SharedConversationPanel from '@/components/extraction/SharedConversationPanel.vue' interface WorkspaceReadinessStatus { has_minimum_entity_types: boolean @@ -83,6 +83,12 @@ const modeLabel = computed(() => : 'Schema Bootstrap', ) +const sessionMode = computed<'schema_bootstrap' | 'extraction_operations'>(() => + statusProjection.value?.workspace_mode === 'extraction_operations' + ? 'extraction_operations' + : 'schema_bootstrap', +) + const canTransition = computed(() => statusProjection.value?.workspace_mode === 'schema_bootstrap' && statusProjection.value?.transition_eligible === true, @@ -138,6 +144,13 @@ const nextSteps = computed(() => { return steps }) +const sessionActivityLines = computed(() => { + const context = extractionSession.value?.runtime_context ?? {} + const candidate = context.activity_lines ?? context.ndjson_activity_lines ?? context.thinking_lines + if (!Array.isArray(candidate)) return [] + return candidate.filter((line): line is string => typeof line === 'string' && line.trim().length > 0) +}) + async function loadWorkspaceStatus() { if (!hasTenant.value || !kgId.value) return loading.value = true @@ -207,7 +220,7 @@ async function loadExtractionSession() { sessionLoading.value = true try { extractionSession.value = await apiFetch( - `/extraction/knowledge-graphs/${kgId.value}/sessions/extraction_operations/active`, + `/extraction/knowledge-graphs/${kgId.value}/sessions/${sessionMode.value}/active`, ) } catch (err) { extractionSession.value = null @@ -261,7 +274,7 @@ async function clearChat() { clearingChat.value = true try { extractionSession.value = await apiFetch( - `/extraction/knowledge-graphs/${kgId.value}/sessions/extraction_operations/clear-chat`, + `/extraction/knowledge-graphs/${kgId.value}/sessions/${sessionMode.value}/clear-chat`, { method: 'POST' }, ) toast.success('Extraction chat cleared') @@ -289,7 +302,7 @@ watch(tenantVersion, () => { watch( () => statusProjection.value?.workspace_mode, (mode) => { - if (mode === 'extraction_operations') { + if (mode) { loadExtractionSession() } }, @@ -484,48 +497,19 @@ watch( -
- - - Extraction Conversation - - Conversation stays visible while you run extraction and manual-mutation operations. - - - -
- - Loading active extraction session... -
-
-
-

{{ entry.role ?? 'system' }}

-

{{ entry.content ?? entry.message ?? '(empty)' }}

-
-

- Session is active. Start by drafting extraction or mutation tasks below. -

-
-
- - -
-
-
- - +
+ + + Operations Workspace diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 316e72777..7a850813b 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -6,6 +6,10 @@ const manageWorkspaceVue = readFileSync( resolve(__dirname, '../pages/knowledge-graphs/[kgId]/manage.vue'), 'utf-8', ) +const sharedConversationPanelVue = readFileSync( + resolve(__dirname, '../components/extraction/SharedConversationPanel.vue'), + 'utf-8', +) describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { it('loads workspace status projection from management API', () => { @@ -37,15 +41,15 @@ describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { expect(manageWorkspaceVue).toContain('active_extraction_operations_session_id') }) - it('keeps extraction conversation panel visible in extraction mode', () => { - expect(manageWorkspaceVue).toContain('Extraction Conversation') - expect(manageWorkspaceVue).toContain('message_history') - expect(manageWorkspaceVue).toContain('statusProjection.workspace_mode === \'extraction_operations\'') + it('uses shared conversation panel for bootstrap and extraction sessions', () => { + expect(manageWorkspaceVue).toContain('SharedConversationPanel') + expect(manageWorkspaceVue).toContain('sessionMode') + expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/active') }) it('supports explicit Clear chat reset for extraction session', () => { expect(manageWorkspaceVue).toContain('clearChat') - expect(manageWorkspaceVue).toContain('/sessions/extraction_operations/clear-chat') + expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/clear-chat') expect(manageWorkspaceVue).toContain('Clear chat') }) @@ -104,3 +108,22 @@ describe('Knowledge Graph Manage Workspace - bootstrap readiness guidance', () = expect(manageWorkspaceVue).toContain('Transition is enabled') }) }) + +describe('Shared conversation panel - extraction UX contract', () => { + it('renders resume-session action and explicit server-side persistence note', () => { + expect(sharedConversationPanelVue).toContain('Resume session') + expect(sharedConversationPanelVue).toContain('No local cache: conversation state is server-side only.') + }) + + it('renders clear-chat confirmation dialog before emitting clear action', () => { + expect(sharedConversationPanelVue).toContain('Clear conversation?') + expect(sharedConversationPanelVue).toContain('confirmClearChat') + expect(sharedConversationPanelVue).toContain("emit('clearChat')") + }) + + it('renders activity/thinking timeline lines and auto-scrolls timeline updates', () => { + expect(sharedConversationPanelVue).toContain('activityTimeline') + expect(sharedConversationPanelVue).toContain('timelineRef') + expect(sharedConversationPanelVue).toContain('scrollTop = timelineRef.value.scrollHeight') + }) +}) From 6e961dec117d6998d994dd5d1a3b1ccd3b4bbdf3 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh Date: Wed, 20 May 2026 16:55:24 -0400 Subject: [PATCH 36/80] feat(workflow): add section-wave subagent orchestration prompts (#713) Extend the subagent-delivery skill with section-wave execution/monitoring rules and add reusable Claude instance system-prompt and launch-packet templates for parallel issue delivery. Co-authored-by: Cursor --- skills/subagent-delivery/SKILL.md | 70 +++++++++++++++++++ .../claude-instance-system-prompt.txt | 67 ++++++++++++++++++ .../section-wave-launch.template.txt | 38 ++++++++++ 3 files changed, 175 insertions(+) create mode 100644 skills/subagent-delivery/claude-instance-system-prompt.txt create mode 100644 skills/subagent-delivery/section-wave-launch.template.txt diff --git a/skills/subagent-delivery/SKILL.md b/skills/subagent-delivery/SKILL.md index 3b9156390..824d6ec4b 100644 --- a/skills/subagent-delivery/SKILL.md +++ b/skills/subagent-delivery/SKILL.md @@ -11,6 +11,10 @@ description: > Follow this protocol for every assigned issue. +System prompt template for spawned Claude instances: + +- `skills/subagent-delivery/claude-instance-system-prompt.txt` + ## Parallel Execution Model Use this model whenever multiple issues are independent: @@ -25,6 +29,26 @@ If two issues touch the same files heavily, either: - serialize those two issues, or - split scope so each agent owns non-overlapping symbols. +## Section-Wave Execution Model (Required) + +When the user asks for "whole sections at a time", execute in waves aligned to tracker sections: + +1. **Section A: Core lifecycle/data** + - `#643 #644 #645 #646 #659 #660 #661 #662 #663` +2. **Section B: Extraction runtime/session** + - `#649 #650 #651 #652 #653 #654` +3. **Section C: Operations/security/integration** + - `#665 #667 #670 #671 #672 #673` + +Wave rules: + +1. Run independent issues in parallel with one Claude instance per issue. +2. Respect dependencies inside the section (foundation issues first). +3. Keep all PRs targeting `feature/manage-knowledge-graph`. +4. Do not start the next section until current section is merged or explicitly deferred. +5. For each section, maintain a live status board: + - `queued`, `in_progress`, `blocked`, `in_review`, `merged` + ## Scope and Inputs Before coding, gather: @@ -40,6 +64,26 @@ Before coding, gather: If acceptance criteria are ambiguous, ask one focused question before implementation. +## Claude Instance Spawn Contract + +For each issue, provide the Claude instance: + +1. Issue ID + title + acceptance criteria summary. +2. Branch naming requirement: + - `feat/issue--` or `fix/issue--` +3. Required reads: + - `AGENTS.md` + - relevant `specs/*.spec.md` + - related tests in touched context +4. TDD requirement: + - tests first, then implementation, then verification +5. Output contract: + - branch + - commit(s) + - test commands and results + - PR URL + - blockers/questions + ## Blocker Question Protocol (Required) Subagents must be able to stop and ask questions immediately. @@ -62,6 +106,13 @@ When blocked: 4. If working from a GitHub issue, mirror the same question as an issue comment so the orchestrator can batch unresolved questions across agents. 5. Continue only non-blocked work; do not guess on blocked decisions. +If a blocker impacts multiple active instances: + +1. Pause affected issues. +2. Continue unaffected issues in parallel. +3. Post one consolidated orchestrator decision update. +4. Resume paused issues with explicit instruction delta. + ## Git Workflow 1. Ensure local target branch is up to date: @@ -117,6 +168,24 @@ When blocked: 4. Re-run tests after conflict resolution. 5. Merge into `feature/manage-knowledge-graph` only after verification. +## Orchestrator Monitoring Loop (Required) + +During active waves, run this loop continuously: + +1. Poll each PR for: + - mergeability + - CI status + - review comments requiring changes +2. If merge conflict appears: + - rebase/merge target branch into issue branch + - resolve conflicts preserving spec behavior + - rerun relevant tests + - push and re-check PR +3. If CI fails: + - fix in same issue branch + - do not move issue scope +4. Update section status board and report progress to user. + ## Orchestrator Handoff Contract Each subagent must hand back: @@ -133,4 +202,5 @@ Each subagent must hand back: - Do not disable hooks. - Do not commit secrets or credentials. - Prefer fakes over mocks in unit tests when testing domain/application behavior. +- Do not invent acceptance criteria beyond the issue/spec without asking. diff --git a/skills/subagent-delivery/claude-instance-system-prompt.txt b/skills/subagent-delivery/claude-instance-system-prompt.txt new file mode 100644 index 000000000..0c3f3260e --- /dev/null +++ b/skills/subagent-delivery/claude-instance-system-prompt.txt @@ -0,0 +1,67 @@ +You are a focused delivery Claude instance assigned to exactly one Kartograph GitHub issue. + +Mission: +- Deliver the assigned issue end-to-end with TDD discipline. +- Open a PR against `feature/manage-knowledge-graph`. +- Stop and ask immediately when blocked by ambiguity. + +Hard constraints: +1. Scope + - Work only on the assigned issue. + - Do not expand scope to neighboring issues. +2. Branching + - Start from latest `feature/manage-knowledge-graph`. + - Use branch `feat/issue--` or `fix/issue--`. +3. Specs and architecture + - Read `AGENTS.md` first. + - Read all relevant `specs/*.spec.md` for your issue. + - Preserve bounded-context boundaries and authorization rules. +4. TDD + - Write/adjust tests first. + - Implement minimal code to satisfy tests. + - Run focused tests; run broader suite as needed by touched context. +5. Safety + - Never use destructive git commands. + - Never commit secrets. + - Never skip required checks. + +Blocker protocol (mandatory): +- Trigger if acceptance criteria are ambiguous, security/tenancy decision is unclear, or behavior is unspecified. +- Stop at decision boundary and ask one concise question with: + - ambiguity summary + - 2-3 concrete options + - recommended option with rationale +- Mirror the blocker question on the GitHub issue as a comment. +- Continue only non-blocked work. + +Execution checklist: +1. Parse issue acceptance criteria. +2. Inspect affected code and tests. +3. Add failing tests for required behavior. +4. Implement and make tests pass. +5. Run lint/type/test for touched area. +6. Commit atomically using conventional commit message. +7. Push branch and open PR to `feature/manage-knowledge-graph`. + +PR body format: +## Summary +- what changed and why +- key architecture/security note + +## Testing +- [x] commands run and results +- [ ] any pending verification + +## Risks +- none or explicit risk + mitigation + +Include `Closes #` where appropriate. + +Required handoff output: +1. Issue ID +2. Branch name +3. Commit SHA(s) +4. Test commands and pass/fail +5. PR URL +6. Open blockers/questions (if any) +7. Assumptions made diff --git a/skills/subagent-delivery/section-wave-launch.template.txt b/skills/subagent-delivery/section-wave-launch.template.txt new file mode 100644 index 000000000..f6ce65822 --- /dev/null +++ b/skills/subagent-delivery/section-wave-launch.template.txt @@ -0,0 +1,38 @@ +Section wave launch template (one Claude instance per issue) + +Prerequisites: +- Read: `skills/subagent-delivery/SKILL.md` +- System prompt: `skills/subagent-delivery/claude-instance-system-prompt.txt` +- Base branch: `feature/manage-knowledge-graph` + +Per-instance launch packet: + +ISSUE: - +TARGET BRANCH: feature/manage-knowledge-graph +WORK BRANCH: feat/issue-<id>-<short-scope> + +Required context files: +- AGENTS.md +- <relevant spec files> +- <relevant code files> +- <relevant tests> + +Acceptance criteria summary: +- <criterion 1> +- <criterion 2> + +Execution requirements: +1) TDD: tests first +2) Implement minimal passing code +3) Run focused tests + lint +4) Commit atomically (conventional commit) +5) Open PR to feature/manage-knowledge-graph +6) Report branch, tests, PR URL, blockers + +Blocker handling: +- Ask one focused blocker question immediately. +- Include options + recommendation. +- Mirror blocker question on issue comment. + +Orchestrator status line format: +[Issue #<id>] <queued|in_progress|blocked|in_review|merged> | Branch: <branch> | PR: <url-or-pending> From 1f6f8f3db17520676126705086b25a8160f0bcfc Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Wed, 20 May 2026 17:08:17 -0400 Subject: [PATCH 37/80] feat(extraction): add sticky runtime and ephemeral worker adapters (#714) Add extraction runtime contracts and in-memory tracer-bullet adapters for sticky session container reuse/reset/timeout and ephemeral worker launch with scoped short-lived credentials and least-privilege validation. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/extraction/infrastructure/__init__.py | 8 + .../infrastructure/workload_runtime.py | 145 +++++++++++++++++ src/api/extraction/ports/__init__.py | 14 ++ src/api/extraction/ports/runtime.py | 98 ++++++++++++ .../infrastructure/test_workload_runtime.py | 150 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 src/api/extraction/infrastructure/workload_runtime.py create mode 100644 src/api/extraction/ports/runtime.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py diff --git a/src/api/extraction/infrastructure/__init__.py b/src/api/extraction/infrastructure/__init__.py index 20d48d352..ec40d91d8 100644 --- a/src/api/extraction/infrastructure/__init__.py +++ b/src/api/extraction/infrastructure/__init__.py @@ -8,11 +8,19 @@ from extraction.infrastructure.runtime_context_builder import ( FilesystemExtractionRuntimeContextBuilder, ) +from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + InMemoryStickySessionRuntimeManager, + ScopedWorkloadCredentialIssuer, +) __all__ = [ "ExtractionEventHandler", "ExtractionAgentSessionRepository", "ExtractionSkillOverrideRepository", "FilesystemExtractionRuntimeContextBuilder", + "InMemoryStickySessionRuntimeManager", + "ScopedWorkloadCredentialIssuer", + "InMemoryEphemeralExtractionWorkerLauncher", ] diff --git a/src/api/extraction/infrastructure/workload_runtime.py b/src/api/extraction/infrastructure/workload_runtime.py new file mode 100644 index 000000000..4f50940be --- /dev/null +++ b/src/api/extraction/infrastructure/workload_runtime.py @@ -0,0 +1,145 @@ +"""In-memory runtime adapters for extraction session/workload execution.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +from ulid import ULID + +from extraction.ports.runtime import ( + EphemeralWorkerLaunchRequest, + EphemeralWorkerLaunchResult, + IEphemeralExtractionWorkerLauncher, + IStickySessionRuntimeManager, + ScopedWorkloadCredentials, + StickySessionRuntimeLease, +) + + +class InMemoryStickySessionRuntimeManager(IStickySessionRuntimeManager): + """Sticky runtime manager with session reuse + timeout cleanup semantics.""" + + def __init__(self, *, session_ttl: timedelta = timedelta(minutes=30)) -> None: + self._session_ttl = session_ttl + self._leases: dict[str, StickySessionRuntimeLease] = {} + + def get_or_start_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + ) -> StickySessionRuntimeLease: + now = datetime.now(UTC) + existing = self._leases.get(session_id) + if existing is not None and existing.expires_at > now: + refreshed = replace( + existing, + last_activity_at=now, + expires_at=now + self._session_ttl, + status="active", + ) + self._leases[session_id] = refreshed + return refreshed + + lease = StickySessionRuntimeLease( + session_id=session_id, + container_id=str(ULID()), + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + status="active", + last_activity_at=now, + expires_at=now + self._session_ttl, + ) + self._leases[session_id] = lease + return lease + + def reset_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + ) -> StickySessionRuntimeLease: + self._leases.pop(session_id, None) + return self.get_or_start_runtime( + session_id=session_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + def cleanup_expired(self, *, now: datetime) -> list[str]: + expired_sessions = [ + session_id + for session_id, lease in self._leases.items() + if lease.expires_at <= now + ] + terminated: list[str] = [] + for session_id in expired_sessions: + lease = self._leases.pop(session_id) + terminated.append(lease.container_id) + return terminated + + +class ScopedWorkloadCredentialIssuer: + """Issues short-lived tenant/KG scoped credentials for extraction workers.""" + + def __init__(self, *, default_ttl: timedelta = timedelta(minutes=15)) -> None: + self._default_ttl = default_ttl + + def issue(self, *, tenant_id: str, knowledge_graph_id: str) -> ScopedWorkloadCredentials: + now = datetime.now(UTC) + return ScopedWorkloadCredentials( + token=str(ULID()), + expires_at=now + self._default_ttl, + scopes=( + f"tenant:{tenant_id}", + f"knowledge_graph:{knowledge_graph_id}", + "workload:extraction", + ), + ) + + +class InMemoryEphemeralExtractionWorkerLauncher(IEphemeralExtractionWorkerLauncher): + """Ephemeral worker launcher that validates scope and tracks active workers.""" + + def __init__(self) -> None: + self._active_workers: dict[str, EphemeralWorkerLaunchRequest] = {} + + @property + def active_worker_count(self) -> int: + return len(self._active_workers) + + def launch( + self, + *, + request: EphemeralWorkerLaunchRequest, + credentials: ScopedWorkloadCredentials, + ) -> EphemeralWorkerLaunchResult: + required_scopes = { + f"tenant:{request.tenant_id}", + f"knowledge_graph:{request.knowledge_graph_id}", + "workload:extraction", + } + available_scopes = set(credentials.scopes) + if not required_scopes.issubset(available_scopes): + raise ValueError("credentials scope does not satisfy workload requirements") + if credentials.expires_at <= datetime.now(UTC): + raise ValueError("credentials are expired") + + worker_id = str(ULID()) + self._active_workers[worker_id] = request + return EphemeralWorkerLaunchResult( + worker_id=worker_id, + status="running", + credentials_expires_at=credentials.expires_at, + ) + + def complete_worker(self, worker_id: str) -> None: + self._active_workers.pop(worker_id, None) + diff --git a/src/api/extraction/ports/__init__.py b/src/api/extraction/ports/__init__.py index d3e72d0e9..2e253a7ea 100644 --- a/src/api/extraction/ports/__init__.py +++ b/src/api/extraction/ports/__init__.py @@ -4,11 +4,25 @@ IExtractionAgentSessionRepository, IExtractionSkillOverrideRepository, ) +from extraction.ports.runtime import ( + EphemeralWorkerLaunchRequest, + EphemeralWorkerLaunchResult, + IEphemeralExtractionWorkerLauncher, + IStickySessionRuntimeManager, + ScopedWorkloadCredentials, + StickySessionRuntimeLease, +) from extraction.ports.services import IExtractionService __all__ = [ "IExtractionService", "IExtractionAgentSessionRepository", "IExtractionSkillOverrideRepository", + "IStickySessionRuntimeManager", + "IEphemeralExtractionWorkerLauncher", + "StickySessionRuntimeLease", + "ScopedWorkloadCredentials", + "EphemeralWorkerLaunchRequest", + "EphemeralWorkerLaunchResult", ] diff --git a/src/api/extraction/ports/runtime.py b/src/api/extraction/ports/runtime.py new file mode 100644 index 000000000..624973ebd --- /dev/null +++ b/src/api/extraction/ports/runtime.py @@ -0,0 +1,98 @@ +"""Runtime port contracts for extraction workload execution.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + + +@dataclass(frozen=True) +class StickySessionRuntimeLease: + """Represents sticky runtime assignment for an active chat session.""" + + session_id: str + container_id: str + user_id: str + knowledge_graph_id: str + mode: str + status: str + last_activity_at: datetime + expires_at: datetime + + +@dataclass(frozen=True) +class ScopedWorkloadCredentials: + """Short-lived credentials issued for one extraction workload scope.""" + + token: str + expires_at: datetime + scopes: tuple[str, ...] + + +@dataclass(frozen=True) +class EphemeralWorkerLaunchRequest: + """Launch payload for an ephemeral extraction worker.""" + + tenant_id: str + knowledge_graph_id: str + session_id: str + sync_run_id: str + job_package_id: str + + +@dataclass(frozen=True) +class EphemeralWorkerLaunchResult: + """Safe result returned after worker launch.""" + + worker_id: str + status: str + credentials_expires_at: datetime + + +class IStickySessionRuntimeManager(Protocol): + """Manages sticky chat runtime containers for active sessions.""" + + def get_or_start_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + ) -> StickySessionRuntimeLease: + """Return current runtime lease or start a new sticky runtime.""" + ... + + def reset_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + ) -> StickySessionRuntimeLease: + """Terminate existing runtime for session and start a clean one.""" + ... + + def cleanup_expired(self, *, now: datetime) -> list[str]: + """Terminate and remove expired sticky runtimes; return container IDs.""" + ... + + +class IEphemeralExtractionWorkerLauncher(Protocol): + """Launches short-lived extraction workers with scoped credentials.""" + + def launch( + self, + *, + request: EphemeralWorkerLaunchRequest, + credentials: ScopedWorkloadCredentials, + ) -> EphemeralWorkerLaunchResult: + """Start ephemeral worker; must not expose credential material.""" + ... + + def complete_worker(self, worker_id: str) -> None: + """Mark worker as completed and terminate runtime resources.""" + ... + diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py new file mode 100644 index 000000000..d2b8a943c --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py @@ -0,0 +1,150 @@ +"""Unit tests for extraction workload runtime infrastructure adapters.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + InMemoryStickySessionRuntimeManager, + ScopedWorkloadCredentialIssuer, +) +from extraction.ports.runtime import ( + EphemeralWorkerLaunchRequest, +) + + +class TestInMemoryStickySessionRuntimeManager: + def test_reuses_same_container_while_session_active(self) -> None: + manager = InMemoryStickySessionRuntimeManager(session_ttl=timedelta(minutes=30)) + + first = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + second = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + + assert first.container_id == second.container_id + assert first.status == "active" + assert second.status == "active" + + def test_reset_rotates_container_for_same_session(self) -> None: + manager = InMemoryStickySessionRuntimeManager(session_ttl=timedelta(minutes=30)) + original = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + + rotated = manager.reset_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + + assert rotated.container_id != original.container_id + assert rotated.status == "active" + + def test_cleanup_terminates_expired_sessions(self) -> None: + manager = InMemoryStickySessionRuntimeManager(session_ttl=timedelta(minutes=5)) + lease = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + cleanup_at = lease.last_activity_at + timedelta(minutes=6) + + terminated = manager.cleanup_expired(now=cleanup_at) + + assert terminated == [lease.container_id] + replacement = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + assert replacement.container_id != lease.container_id + + +class TestEphemeralWorkerLauncher: + def test_launch_requires_credentials_scoped_to_request(self) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + launcher = InMemoryEphemeralExtractionWorkerLauncher() + wrong_scope = issuer.issue( + tenant_id="tenant-2", + knowledge_graph_id="kg-2", + ) + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + with pytest.raises(ValueError, match="scope"): + launcher.launch(request=request, credentials=wrong_scope) + + def test_launch_uses_ephemeral_worker_and_hides_credential_material(self) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + launcher = InMemoryEphemeralExtractionWorkerLauncher() + scoped_credentials = issuer.issue(tenant_id="tenant-1", knowledge_graph_id="kg-1") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + result = launcher.launch(request=request, credentials=scoped_credentials) + + assert result.worker_id + assert result.status == "running" + assert result.credentials_expires_at > datetime.now(UTC) + assert not hasattr(result, "token") + assert launcher.active_worker_count == 1 + + def test_complete_worker_terminates_container(self) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + launcher = InMemoryEphemeralExtractionWorkerLauncher() + scoped_credentials = issuer.issue(tenant_id="tenant-1", knowledge_graph_id="kg-1") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + result = launcher.launch(request=request, credentials=scoped_credentials) + + launcher.complete_worker(result.worker_id) + + assert launcher.active_worker_count == 0 + + +class TestScopedWorkloadCredentialIssuer: + def test_issues_short_lived_credentials_with_least_privilege_scope(self) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=15)) + + issued = issuer.issue(tenant_id="tenant-9", knowledge_graph_id="kg-9") + + assert issued.expires_at > datetime.now(UTC) + assert issued.scopes == ( + "tenant:tenant-9", + "knowledge_graph:kg-9", + "workload:extraction", + ) + assert issued.token From 581d7d11f0a235afaea9a35ec836aa61d4735513 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Wed, 20 May 2026 17:32:40 -0400 Subject: [PATCH 38/80] harden ingestion credential handling and add end-to-end flow validation (#715) Prevent credential leakage by keeping runtime secrets out of event payloads and redacting token-like error output, while adding an integration flow test that validates workspace transition and mutation-run metadata visibility end-to-end. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../ingestion/infrastructure/event_handler.py | 23 ++- src/api/main.py | 9 +- ...test_workspace_extraction_mutation_flow.py | 176 ++++++++++++++++++ .../test_ingestion_event_handler.py | 60 ++++++ .../unit/test_sessioned_ingestion_handler.py | 11 +- 5 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index 27ea29e5f..6eb08ffb2 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import re from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -49,10 +50,29 @@ def supported_event_types(self) -> frozenset[str]: """Return event types handled by this handler.""" return frozenset({"SyncStarted"}) + @staticmethod + def _redact_sensitive_error(message: str) -> str: + """Redact token-like secrets from error strings before persistence.""" + patterns = ( + # GitHub PAT prefixes + re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), + # Generic bearer tokens + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]{16,}\b"), + # Common key/value credential leaks + re.compile( + r"(?i)\b(token|access_token|password|api[_-]?key)\b\s*[:=]\s*['\"]?[^\s,'\"]+" + ), + ) + redacted = message + for pattern in patterns: + redacted = pattern.sub("***REDACTED***", redacted) + return redacted + async def handle( self, event_type: str, payload: dict[str, Any], + runtime_credentials: dict[str, str] | None = None, ) -> None: """Process a SyncStarted event by running the ingestion pipeline. @@ -100,6 +120,7 @@ async def handle( credentials_path=payload.get("credentials_path"), tenant_id=payload.get("tenant_id"), credentials=payload.get("credentials"), + credentials=runtime_credentials or payload.get("credentials"), baseline_commit=payload.get("baseline_commit"), ) except asyncio.CancelledError: @@ -112,7 +133,7 @@ async def handle( payload={ "sync_run_id": sync_run_id, "data_source_id": data_source_id, - "error": str(exc), + "error": self._redact_sensitive_error(str(exc)), "occurred_at": now.isoformat(), }, occurred_at=now, diff --git a/src/api/main.py b/src/api/main.py index 9204c77de..1b0236b2f 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -273,9 +273,6 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: ) except KeyError: credentials = {} - if credentials: - enriched_payload["credentials"] = credentials - tracked_head = await self._resolve_github_tracked_head_commit( connection_config=ds.connection_config, credentials=credentials, @@ -292,7 +289,11 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: ): enriched_payload["no_changes_detected"] = True - await ingestion_handler.handle(event_type, enriched_payload) + await ingestion_handler.handle( + event_type, + enriched_payload, + runtime_credentials=credentials, + ) await session.commit() diff --git a/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py b/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py new file mode 100644 index 000000000..4862258ab --- /dev/null +++ b/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py @@ -0,0 +1,176 @@ +"""Integration test for workspace transition and mutation-log run visibility.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import text + +from management.application.services.data_source_service import DataSourceService +from management.application.services.knowledge_graph_service import KnowledgeGraphService +from management.domain.aggregates import KnowledgeGraph +from management.domain.entities.data_source_sync_run import MutationLogRunMetadata +from management.domain.value_objects import EdgeTypeDefinition, NodeTypeDefinition, OntologyConfig +from management.presentation.data_sources.models import SyncRunResponse +from shared_kernel.datasource_types import DataSourceAdapterType +from tests.fakes.authorization import InMemoryAuthorizationProvider + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_workspace_transition_then_extraction_run_metadata_visibility( + async_session, + clean_management_data: None, + knowledge_graph_repository, + data_source_repository, + data_source_sync_run_repository, + test_tenant: str, + test_workspace: str, +) -> None: + """End-to-end flow: validate/transition workspace and project mutation-run metadata.""" + required_columns = ( + "maintenance_schedule", + "maintenance_run_history", + ) + for column_name in required_columns: + column_check = await async_session.execute( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'knowledge_graphs' + AND column_name = :column_name + """ + ), + {"column_name": column_name}, + ) + if column_check.scalar_one_or_none() is None: + pytest.skip( + f"knowledge_graphs.{column_name} is missing in local integration database" + ) + + user_id = "user-integration-001" + authz = InMemoryAuthorizationProvider() + + kg_service = KnowledgeGraphService( + session=async_session, + knowledge_graph_repository=knowledge_graph_repository, + data_source_repository=data_source_repository, + sync_run_repository=data_source_sync_run_repository, + secret_store=None, + authz=authz, + scope_to_tenant=test_tenant, + ) + ds_service = DataSourceService( + session=async_session, + data_source_repository=data_source_repository, + knowledge_graph_repository=knowledge_graph_repository, + sync_run_repository=data_source_sync_run_repository, + secret_store=None, + authz=authz, + scope_to_tenant=test_tenant, + ) + + knowledge_graph = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Integration Flow KG", + description="Workspace transition + extraction run visibility", + created_by=user_id, + ) + knowledge_graph.set_ontology( + OntologyConfig( + node_types=( + NodeTypeDefinition(label="Repository"), + NodeTypeDefinition( + label="SeedNode", + prepopulated=True, + prepopulated_instance_count=1, + ), + ), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("SeedNode",), + ), + ), + ) + ) + async with async_session.begin(): + await knowledge_graph_repository.save(knowledge_graph) + + await authz.write_relationship( + f"knowledge_graph:{knowledge_graph.id.value}", + "admin", + f"user:{user_id}", + ) + + status_before = await kg_service.get_workspace_status( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + assert status_before is not None + assert status_before.workspace_mode.value == "schema_bootstrap" + assert status_before.transition_eligible is True + + validated = await kg_service.validate_workspace( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + assert validated.transition_eligible is True + assert validated.readiness.blocking_reasons == () + + transitioned = await kg_service.transition_workspace_to_extraction( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + assert transitioned.workspace_mode.value == "extraction_operations" + assert transitioned.session_pointers.active_extraction_operations_session_id is not None + + data_source = await ds_service.create( + user_id=user_id, + kg_id=knowledge_graph.id.value, + name="Integration Source", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"repo_url": "https://github.com/example/repo"}, + ) + await authz.write_relationship( + f"data_source:{data_source.id.value}", + "admin", + f"user:{user_id}", + ) + + sync_run = await ds_service.trigger_sync( + user_id=user_id, + ds_id=data_source.id.value, + ) + assert sync_run.status == "pending" + + sync_run.status = "completed" + sync_run.completed_at = datetime.now(UTC) + sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="mlog-int-001", + knowledge_graph_id=knowledge_graph.id.value, + session_id=transitioned.session_pointers.active_extraction_operations_session_id, + actor_id=user_id, + started_at=sync_run.started_at, + completed_at=sync_run.completed_at, + token_usage_total=2048, + cost_total_usd=1.37, + operation_counts={"create_node": 12, "create_edge": 8}, + ) + async with async_session.begin(): + await data_source_sync_run_repository.save(sync_run) + + runs = await data_source_sync_run_repository.find_by_data_source(data_source.id.value) + assert len(runs) == 1 + projected = SyncRunResponse.from_domain(runs[0]) + + assert projected.mutation_log_id == "mlog-int-001" + assert projected.session_id == transitioned.session_pointers.active_extraction_operations_session_id + assert projected.token_usage_total == 2048 + assert projected.cost_total_usd == pytest.approx(1.37) + assert projected.operation_counts == {"create_node": 12, "create_edge": 8} diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index 408d02bb6..0b1e6069b 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -170,6 +170,24 @@ async def test_passes_baseline_and_credentials_through_payload( assert call["baseline_commit"] == "abc123" assert call["credentials"] == {"token": "secret"} + async def test_prefers_runtime_credentials_over_payload_credentials( + self, + handler: IngestionEventHandler, + ingestion_service: _FakeIngestionService, + ): + """Runtime credentials override payload credentials to avoid payload leakage.""" + payload = _sync_started_payload() + payload["credentials"] = {"token": "payload-token"} + + await handler.handle( + "SyncStarted", + payload, + runtime_credentials={"token": "runtime-token"}, + ) + + call = ingestion_service.calls[0] + assert call["credentials"] == {"token": "runtime-token"} + async def test_emits_job_package_produced_on_success( self, handler: IngestionEventHandler, @@ -245,6 +263,48 @@ async def test_emits_ingestion_failed_on_adapter_error( assert event["payload"]["data_source_id"] == "ds-001" assert "credentials expired" in event["payload"]["error"] + async def test_redacts_secret_material_from_failure_payload( + self, + outbox: _FakeOutboxRepository, + ): + """Failure payload must redact token-shaped credential values.""" + + class _LeakyService(_FakeIngestionService): + async def run( # type: ignore[override] + self, + sync_run_id: str, + data_source_id: str, + knowledge_graph_id: str, + adapter_type: str, + connection_config: dict[str, str], + credentials_path: str | None, + credentials: dict[str, str] | None = None, + baseline_commit: str | None = None, + ) -> JobPackageId: + raise RuntimeError( + "github auth failed for token ghp_1234567890abcdef1234567890abcdef1234" + ) + + handler = IngestionEventHandler( + ingestion_service=_LeakyService(), + outbox=outbox, + ) + payload = _sync_started_payload(sync_run_id="run-redact") + await handler.handle( + "SyncStarted", + payload, + runtime_credentials={ + "token": "ghp_1234567890abcdef1234567890abcdef1234" + }, + ) + + event = outbox.appended[0] + assert event["event_type"] == "IngestionFailed" + assert "ghp_1234567890abcdef1234567890abcdef1234" not in event["payload"][ + "error" + ] + assert "***REDACTED***" in event["payload"]["error"] + async def test_ingestion_failed_aggregate_type( self, failing_service: _FakeIngestionService, diff --git a/src/api/tests/unit/test_sessioned_ingestion_handler.py b/src/api/tests/unit/test_sessioned_ingestion_handler.py index 53817275e..b0fc4e7ec 100644 --- a/src/api/tests/unit/test_sessioned_ingestion_handler.py +++ b/src/api/tests/unit/test_sessioned_ingestion_handler.py @@ -101,7 +101,11 @@ async def test_sessioned_ingestion_handler_prepares_commit_context(): call_payload = ingestion_handler.handle.call_args.args[1] assert call_payload["baseline_commit"] == "baseline123" assert call_payload["tracked_branch_head_commit"] == "head456" - assert call_payload["credentials"] == {"token": "tok"} + assert "credentials" not in call_payload + assert ( + ingestion_handler.handle.call_args.kwargs["runtime_credentials"] + == {"token": "tok"} + ) ds_repo.save.assert_awaited_once() assert data_source.tracked_branch_head_commit == "head456" @@ -169,4 +173,9 @@ async def test_sessioned_ingestion_handler_sets_no_changes_flag_when_heads_match assert call_payload["baseline_commit"] == "baseline123" assert call_payload["tracked_branch_head_commit"] == "baseline123" assert call_payload["no_changes_detected"] is True + assert "credentials" not in call_payload + assert ( + ingestion_handler.handle.call_args.kwargs["runtime_credentials"] + == {"token": "tok"} + ) From cbe44853cace8fb6dc131e06d4035fa5d47e6aac Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Thu, 21 May 2026 11:47:48 -0400 Subject: [PATCH 39/80] test-integration fixes --- ...test_workspace_extraction_mutation_flow.py | 41 +++++++++++-------- .../integration/query/test_kg_resource.py | 8 ++-- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py b/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py index 4862258ab..dca78f60d 100644 --- a/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py +++ b/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py @@ -50,6 +50,9 @@ async def test_workspace_transition_then_extraction_run_metadata_visibility( pytest.skip( f"knowledge_graphs.{column_name} is missing in local integration database" ) + # The column introspection query starts an implicit transaction on the session. + # Reset it before entering explicit transaction scopes below. + await async_session.rollback() user_id = "user-integration-001" authz = InMemoryAuthorizationProvider() @@ -80,25 +83,24 @@ async def test_workspace_transition_then_extraction_run_metadata_visibility( description="Workspace transition + extraction run visibility", created_by=user_id, ) - knowledge_graph.set_ontology( - OntologyConfig( - node_types=( - NodeTypeDefinition(label="Repository"), - NodeTypeDefinition( - label="SeedNode", - prepopulated=True, - prepopulated_instance_count=1, - ), + ontology_config = OntologyConfig( + node_types=( + NodeTypeDefinition(label="Repository"), + NodeTypeDefinition( + label="SeedNode", + prepopulated=True, + prepopulated_instance_count=1, ), - edge_types=( - EdgeTypeDefinition( - label="CONTAINS", - source_labels=("Repository",), - target_labels=("SeedNode",), - ), + ), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("SeedNode",), ), - ) + ), ) + knowledge_graph.set_ontology(ontology_config) async with async_session.begin(): await knowledge_graph_repository.save(knowledge_graph) @@ -107,6 +109,11 @@ async def test_workspace_transition_then_extraction_run_metadata_visibility( "admin", f"user:{user_id}", ) + await kg_service.save_ontology( + user_id=user_id, + kg_id=knowledge_graph.id.value, + config=ontology_config, + ) status_before = await kg_service.get_workspace_status( user_id=user_id, @@ -139,7 +146,7 @@ async def test_workspace_transition_then_extraction_run_metadata_visibility( ) await authz.write_relationship( f"data_source:{data_source.id.value}", - "admin", + "manage", f"user:{user_id}", ) diff --git a/src/api/tests/integration/query/test_kg_resource.py b/src/api/tests/integration/query/test_kg_resource.py index 68367e747..d219751c1 100644 --- a/src/api/tests/integration/query/test_kg_resource.py +++ b/src/api/tests/integration/query/test_kg_resource.py @@ -196,8 +196,8 @@ async def kg1_id( await async_session.execute( text( "INSERT INTO knowledge_graphs " - "(id, tenant_id, workspace_id, name, description, created_at, updated_at) " - "VALUES (:id, :tenant_id, :workspace_id, :name, :desc, NOW(), NOW())" + "(id, tenant_id, workspace_id, name, description, maintenance_run_history, created_at, updated_at) " + "VALUES (:id, :tenant_id, :workspace_id, :name, :desc, '[]'::jsonb, NOW(), NOW())" ), { "id": kg_id, @@ -227,8 +227,8 @@ async def kg2_id( await async_session.execute( text( "INSERT INTO knowledge_graphs " - "(id, tenant_id, workspace_id, name, description, created_at, updated_at) " - "VALUES (:id, :tenant_id, :workspace_id, :name, :desc, NOW(), NOW())" + "(id, tenant_id, workspace_id, name, description, maintenance_run_history, created_at, updated_at) " + "VALUES (:id, :tenant_id, :workspace_id, :name, :desc, '[]'::jsonb, NOW(), NOW())" ), { "id": kg_id, From 984204622136d47f383b5f0c04d788370ad0398d Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 22 May 2026 11:36:52 -0400 Subject: [PATCH 40/80] kg-manage-experience --- specs/ui/experience.spec.md | 59 ++----- specs/ui/kg-manage-experience.spec.md | 237 ++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 specs/ui/kg-manage-experience.spec.md diff --git a/specs/ui/experience.spec.md b/specs/ui/experience.spec.md index dd1f091d1..f17216337 100644 --- a/specs/ui/experience.spec.md +++ b/specs/ui/experience.spec.md @@ -543,49 +543,16 @@ The system SHALL expose knowledge graph row actions as Manage, Query, and Delete - WHEN navigation completes - THEN the user lands on that knowledge graph's mode-aware workspace page -### Requirement: Bootstrap to Extraction Transition -The system SHALL provide a UI-gated transition from schema bootstrap mode to extraction operations mode. - -#### Scenario: Validate action -- GIVEN a user with `edit` permission on a knowledge graph in bootstrap mode -- WHEN the user clicks Validate -- THEN validation results are displayed in the workspace -- AND transition action remains unavailable until validation passes - -#### Scenario: Go to extraction action -- GIVEN bootstrap validation has passed -- WHEN the user clicks "Go to Extraction/Mutations" -- THEN the UI transitions the knowledge graph into extraction operations mode -- AND a new extraction-mode agent session is started - -### Requirement: Unified Extraction Workspace -The system SHALL present extraction jobs and minor direct edits in one workspace. - -#### Scenario: Conversation-first layout -- GIVEN a user in extraction operations mode -- THEN the conversation panel remains visible as the primary surface -- AND the lower workspace area is tabbed for operational views - -#### Scenario: Clear chat reset -- GIVEN an active extraction conversation session -- WHEN the user clicks Clear chat -- THEN the current chat history is cleared -- AND a new clean session is started for the same user and knowledge graph - -#### Scenario: Tabbed operations area -- GIVEN the extraction workspace -- WHEN the user switches tabs -- THEN extraction-job controls, manual mutation tools, and run/log views are available without leaving the page - -### Requirement: MutationLog Browser -The system SHALL provide a knowledge-graph-scoped MutationLog browser. - -#### Scenario: Scoped listing -- GIVEN the user is viewing a specific knowledge graph -- WHEN the user opens MutationLogs -- THEN only mutation log runs associated with that knowledge graph are listed - -#### Scenario: Run detail panel -- GIVEN a mutation log run is selected -- WHEN details are shown -- THEN the UI displays run summary, per-entry operation previews, token/cost metrics, and operation counts by type +### Requirement: Detailed KG Manage Experience Specification +The system SHALL define detailed KG Manage workspace behavior in a dedicated canonical UX spec to avoid drift. + +#### Scenario: Canonical detailed behavior source +- GIVEN requirements for the graph manage page flow, conversation UX, modes, and step cards +- THEN details are defined in `specs/ui/kg-manage-experience.spec.md` +- AND this file remains the high-level UX umbrella for broader product behavior + +#### Scenario: Cross-spec consistency +- GIVEN updates to KG manage interaction behavior +- WHEN UX requirements are changed +- THEN `specs/ui/kg-manage-experience.spec.md` is updated first +- AND summary references here are kept consistent with it diff --git a/specs/ui/kg-manage-experience.spec.md b/specs/ui/kg-manage-experience.spec.md new file mode 100644 index 000000000..d91d9eb24 --- /dev/null +++ b/specs/ui/kg-manage-experience.spec.md @@ -0,0 +1,237 @@ +# Knowledge Graph Manage Experience + +## Purpose +Define the canonical UX for the Knowledge Graph `Manage` flow in Kartograph, modeled after the proven interaction patterns in k-extract project workspace and design pages. + +This spec is the detailed source of truth for KG management UI behavior. + +## Scope +In scope: +- `Knowledge Graphs -> Manage` entry flow. +- KG workspace layout, step cards, and progress semantics. +- Graph Management conversation-first interaction model. +- Mode switch behavior and lower-panel content contracts. +- Error/loading/empty/forbidden states and keyboard interactions. + +Out of scope: +- Backend domain rules already specified in management/extraction/graph specs. +- Container runtime implementation details. + +## Page Contracts +### Page: KG Manage Workspace Overview +Route: `/knowledge-graphs/{kgId}/manage` + +Primary intent: +- Provide a project-workspace-style control center for the selected graph. +- Help the user decide the next action with minimal navigation overhead. + +Top-level regions: +- Header (graph name/identity, back action) +- `Project workspace` section +- `Suggested next step` callout with one primary CTA +- Step-card grid (`Data Sources`, `Graph Management`, `MutationLogs`, `Maintain`) + +### Page: KG Graph Management +Route: `/knowledge-graphs/{kgId}/manage` (same page surface, `Graph Management` active state) + +Primary intent: +- Keep conversation as the main control surface. +- Support three operation modes without session fragmentation. + +Top-level regions: +- Graph Management mode switcher +- Shared persistent chat box +- Hybrid lower panel: + - left rail: status/artifacts + - right detail panel: mode-specific workspace + +## Requirements + +### Requirement: Manage Entry Navigation +The system SHALL route users from knowledge graph list rows into a graph-scoped manage workspace. + +#### Scenario: Manage route entry +- GIVEN the user is on the Knowledge Graphs list +- WHEN the user clicks `Manage` for a knowledge graph +- THEN navigation lands on `/knowledge-graphs/{kgId}/manage` +- AND the page header includes graph identity and a back action +- AND the selected graph context is available to all step cards without re-selection + +### Requirement: Workspace Shell and Step Cards +The system SHALL provide a project-workspace-style shell with actionable step cards. + +#### Scenario: Step card set +- GIVEN the user opens KG manage workspace +- THEN the step card grid contains exactly: + - `Data Sources` + - `Graph Management` + - `MutationLogs` + - `Maintain` + +#### Scenario: Suggested next step +- GIVEN workspace status and run metadata are available +- WHEN the manage page renders +- THEN a `Suggested next step` callout is shown above the card grid +- AND the callout CTA routes to the corresponding step destination +- AND the CTA label uses action wording (`Open`, `Revisit`, or `Run`) + +#### Scenario: Card status semantics +- GIVEN each step has completion/readiness metadata +- WHEN cards render +- THEN each card displays status tint and label (`ready`, `in_progress`, `needs_attention`, or `blocked`) +- AND each card includes one primary action (`Open` or `Revisit`) +- AND each card includes one line of status detail text suitable for quick scanning + +### Requirement: Data Sources Step Behavior +The system SHALL preserve the established data-source operations experience while keeping graph context. + +#### Scenario: Graph-scoped data source step +- GIVEN the user opens `Data Sources` from KG manage workspace +- THEN the destination is pre-scoped to the selected knowledge graph +- AND existing commit cues, maintenance readiness, and diff summary behaviors remain available +- AND returning to manage workspace preserves the current graph context + +### Requirement: Graph Management Conversation-First Layout +The system SHALL use a single persistent chat panel as the primary control surface. + +#### Scenario: Persistent shared chat +- GIVEN the user is in Graph Management +- WHEN the user changes modes +- THEN chat history remains in the same session scope +- AND the active mode changes assistant skill framing/instructions rather than opening a new chat +- AND the input placeholder/help text updates to reflect the selected mode + +#### Scenario: Top-section controls +- GIVEN the Graph Management page +- THEN the top section includes: + - mode switcher + - clear chat action + - session status indicator + - validation affordance when relevant to mode +- AND these controls are visible without scrolling on desktop layout + +### Requirement: Graph Management Modes +The system SHALL support three operator modes on one page. + +#### Scenario: Supported modes +- GIVEN the mode selector in Graph Management +- THEN available modes are: + - `Initial Schema Design` + - `Extraction Jobs` + - `One-off Mutations` + +#### Scenario: Mode-specific AI behavior +- GIVEN the user selects a mode +- WHEN the assistant responds or suggests next actions +- THEN the assistant uses mode-appropriate skills and guidance +- AND does not lose shared conversational context +- AND assistant suggestions are constrained to the current knowledge graph scope + +### Requirement: Hybrid Lower Panel +The system SHALL provide a hybrid lower panel with shared status/artifact rail and mode-specific detail panel. + +#### Scenario: Shared rail +- GIVEN the Graph Management lower panel +- THEN a persistent rail shows graph-management status/artifact items relevant across modes +- AND each item includes status plus last-updated metadata +- AND rail items support keyboard focus and selection + +#### Scenario: Mode-specific detail panel +- GIVEN a selected mode and selected rail item +- THEN the right-side detail panel renders mode-specific content: + - `Initial Schema Design`: schema artifacts, readiness blockers, validation controls + - `Extraction Jobs`: job setup, execution controls, and job run context + - `One-off Mutations`: mutation authoring controls and submit/preview context +- AND switching modes preserves rail selection when the selected item is valid in the new mode + +#### Scenario: Schema design parity behavior +- GIVEN `Initial Schema Design` mode is active +- THEN the lower panel exposes schema-focused artifact/status content analogous to k-extract design-artifact workflow +- AND the user can inspect and revise schema-related content without leaving Graph Management + +### Requirement: MutationLogs Step Experience +The system SHALL provide graph-scoped mutation run visibility. + +#### Scenario: Graph-scoped mutation run list +- GIVEN the user opens the `MutationLogs` step +- THEN only runs for the selected knowledge graph are listed +- AND list items show status, timestamp, source, and run identifier +- AND the list defaults to newest run first + +#### Scenario: Run detail richness +- GIVEN a selected run +- THEN the detail panel shows run summary, session reference, token/cost metrics, and operation-class counts +- AND supports per-entry operation preview when available +- AND gracefully displays a no-preview state when detailed entries are unavailable + +### Requirement: Maintain Step Experience +The system SHALL provide incremental maintenance entry points from the manage workspace. + +#### Scenario: Maintenance readiness actioning +- GIVEN tracked source changes are detected +- WHEN the user opens `Maintain` +- THEN the UI highlights change readiness and provides the maintenance execution path +- AND relevant diff summary context is available before execution +- AND the user can navigate back to workspace overview without losing step status context + +### Requirement: Session and Reset Behavior +The system SHALL support explicit conversational reset without losing auditability. + +#### Scenario: Clear chat reset +- GIVEN an active graph-management chat +- WHEN the user clicks `Clear chat` +- THEN the current chat thread resets +- AND a new clean session starts for the same user/knowledge-graph scope +- AND historical session records remain available for audit/history views +- AND mode selection remains unchanged after reset + +### Requirement: State and Accessibility Contracts +The system SHALL provide predictable state handling and keyboard affordances. + +#### Scenario: Loading and empty states +- GIVEN initial page load or step data fetch +- THEN each major section shows explicit loading placeholders +- AND empty states provide direct next actions +- AND loading/error state messaging is step-specific (not generic across all steps) + +#### Scenario: Forbidden state +- GIVEN the user lacks required permission for a step action +- WHEN the action is attempted +- THEN the UI shows a clear forbidden state/message +- AND avoids partial, misleading updates +- AND disabled actions explain why access is restricted + +#### Scenario: Keyboard behavior +- GIVEN the chat input is focused +- THEN `Enter` sends and `Shift+Enter` inserts newline +- AND mode switch/step navigation remains keyboard reachable +- AND primary step-card actions can be triggered by keyboard focus + Enter/Space + +## Traceability to UI Surfaces + +Primary surfaces expected to implement this UX: +- `src/dev-ui/app/pages/knowledge-graphs/index.vue` +- `src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue` +- `src/dev-ui/app/components/extraction/SharedConversationPanel.vue` + +Requirement-to-surface mapping: +- Manage Entry Navigation -> `knowledge-graphs/index.vue` +- Workspace Shell and Step Cards -> `knowledge-graphs/[kgId]/manage.vue` +- Graph Management Conversation-First Layout -> `knowledge-graphs/[kgId]/manage.vue`, `SharedConversationPanel.vue` +- Graph Management Modes -> `knowledge-graphs/[kgId]/manage.vue` +- Hybrid Lower Panel -> `knowledge-graphs/[kgId]/manage.vue` +- MutationLogs Step Experience -> `knowledge-graphs/[kgId]/manage.vue` +- Maintain Step Experience -> `knowledge-graphs/[kgId]/manage.vue` (+ data-source operations surface) +- Session and Reset Behavior -> `knowledge-graphs/[kgId]/manage.vue`, `SharedConversationPanel.vue` +- State and Accessibility Contracts -> `knowledge-graphs/[kgId]/manage.vue`, `SharedConversationPanel.vue` + +## Issue Mapping +Detailed implementation tracking for this spec has been externalized to GitHub issues: +- `#722` workspace overview parity +- `#723` graph-management parity (shared chat + mode switch + hybrid panel) +- `#724` mutationlogs step hardening +- `#725` accessibility and state contracts + +## Notes for Issue Alignment +- In-place unified operations parity is tracked by GitHub issue `#720`. +- Per-run operation preview depth is tracked by GitHub issue `#721`. From 200d1b4977c4701d32d078b37ae6e5404d1f1ce0 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:19:35 -0400 Subject: [PATCH 41/80] feat(dev-ui): add KG manage workspace overview parity (#722) (#726) Introduce project-workspace step cards, suggested next-step CTA, and graph-scoped navigation for Data Sources and Maintain while preserving existing graph-management flows. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/dev-ui/app/pages/data-sources/index.vue | 51 ++- .../pages/knowledge-graphs/[kgId]/manage.vue | 265 ++++++++++++++- .../knowledge-graph-manage-workspace.test.ts | 192 +++++++++++ src/dev-ui/app/utils/kgManageWorkspace.ts | 319 ++++++++++++++++++ 4 files changed, 815 insertions(+), 12 deletions(-) create mode 100644 src/dev-ui/app/utils/kgManageWorkspace.ts diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index e38781d89..1ab0fa470 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -638,6 +638,8 @@ async function approveOntology() { const dataSources = ref<DataSourceItem[]>([]) const loadingDataSources = ref(false) +const scopedKnowledgeGraphId = ref('') +const manageReturnKgId = ref('') const expandedDiffLists = ref<Record<string, boolean>>({}) const refreshingCommitRefs = ref<Record<string, boolean>>({}) const adoptingBaselines = ref<Record<string, boolean>>({}) @@ -647,6 +649,19 @@ function isMaintenanceReady(ds: DataSourceItem): boolean { return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit } +const visibleDataSources = computed(() => { + if (!scopedKnowledgeGraphId.value) return dataSources.value + return dataSources.value.filter( + (ds) => ds.knowledge_graph_id === scopedKnowledgeGraphId.value, + ) +}) + +const manageWorkspaceReturnUrl = computed(() => + manageReturnKgId.value + ? `/knowledge-graphs/${manageReturnKgId.value}/manage` + : '', +) + function isDiffExpanded(dsId: string): boolean { return expandedDiffLists.value[dsId] === true } @@ -958,11 +973,24 @@ onMounted(async () => { // When the user clicks "Add Data Source" from the post-KG-creation toast on // /knowledge-graphs, they are sent to /data-sources?kg_id=<new-kg-id>. Reading // this param here ensures the wizard opens immediately with the right KG chosen. + // Manage workspace navigation contract: ?kg_id=<id>&from=manage preserves graph scope + // without auto-opening the creation wizard (see buildDataSourcesStepUrl). const preselectedKgId = route.query.kg_id as string | undefined - if (preselectedKgId) { + const fromManage = route.query.from === 'manage' + const focusMaintain = route.query.focus === 'maintain' + + if (fromManage && preselectedKgId) { + scopedKnowledgeGraphId.value = preselectedKgId + manageReturnKgId.value = preselectedKgId + selectedMaintenanceKnowledgeGraphId.value = preselectedKgId + } else if (preselectedKgId) { await nextTick() openWizard(preselectedKgId) } + + if (focusMaintain && preselectedKgId) { + selectedMaintenanceKnowledgeGraphId.value = preselectedKgId + } }) onUnmounted(() => { @@ -1312,10 +1340,19 @@ async function handleDeleteDs() { </p> </div> </div> - <Button :disabled="!hasTenant" @click="openWizard"> - <Plus class="mr-2 size-4" /> - Add Data Source - </Button> + <div class="flex items-center gap-2"> + <Button + v-if="manageWorkspaceReturnUrl" + variant="outline" + @click="navigateTo(manageWorkspaceReturnUrl)" + > + Back to workspace overview + </Button> + <Button :disabled="!hasTenant" @click="openWizard(scopedKnowledgeGraphId || undefined)"> + <Plus class="mr-2 size-4" /> + Add Data Source + </Button> + </div> </div> <Separator /> @@ -1505,7 +1542,7 @@ async function handleDeleteDs() { </Card> <!-- Empty state (no data sources yet) --> - <div v-if="dataSources.length === 0" class="flex flex-col items-center gap-4 py-16 text-center"> + <div v-if="visibleDataSources.length === 0" class="flex flex-col items-center gap-4 py-16 text-center"> <div class="rounded-full bg-muted p-5"> <Cable class="size-10 text-muted-foreground" /> </div> @@ -1525,7 +1562,7 @@ async function handleDeleteDs() { <!-- Data source list (shown when sources exist) --> <div v-else class="space-y-3"> - <div v-for="ds in dataSources" :key="ds.id" class="rounded-lg border bg-card"> + <div v-for="ds in visibleDataSources" :key="ds.id" class="rounded-lg border bg-card"> <div class="flex items-center justify-between p-4"> <div class="flex items-center gap-3"> <div class="rounded-md bg-muted p-2"> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 27119a4b7..21eaf07e4 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -8,6 +8,17 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com import { Separator } from '@/components/ui/separator' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import SharedConversationPanel from '@/components/extraction/SharedConversationPanel.vue' +import { + buildDataSourcesStepUrl, + buildMaintainStepUrl, + buildManageStepUrl, + buildSuggestedNextStep, + buildWorkspaceStepCards, + parseManageStepQuery, + resolveStepDestination, + stepStatusTintClass, + type WorkspaceStepId, +} from '@/utils/kgManageWorkspace' interface WorkspaceReadinessStatus { has_minimum_entity_types: boolean @@ -31,9 +42,17 @@ interface WorkspaceStatusResponse { session_pointers: WorkspaceSessionPointers } +interface KnowledgeGraphIdentity { + id: string + name: string + description?: string | null +} + interface DataSourceRef { id: string name: string + last_extraction_baseline_commit?: string | null + tracked_branch_head_commit?: string | null } interface MutationLogRunView { @@ -64,6 +83,9 @@ const { hasTenant, tenantVersion } = useTenant() const { extractErrorMessage } = useErrorHandler() const { apiFetch } = useApiClient() const kgId = computed(() => String(route.params.kgId ?? '')) +const kgIdentity = ref<KnowledgeGraphIdentity | null>(null) +const dataSourceCount = ref(0) +const maintenanceReadyCount = ref(0) const loading = ref(false) const validating = ref(false) const transitioning = ref(false) @@ -77,6 +99,24 @@ const mutationLogLoading = ref(false) const mutationLogRuns = ref<MutationLogRunView[]>([]) const selectedMutationLogRunId = ref<string | null>(null) +const activeStep = computed(() => parseManageStepQuery(route.query.step)) +const showOverview = computed(() => activeStep.value === null) + +const workspaceOverviewInput = computed(() => ({ + kgId: kgId.value, + dataSourceCount: dataSourceCount.value, + maintenanceReadyCount: maintenanceReadyCount.value, + mutationLogRunCount: mutationLogRuns.value.length, + workspaceStatus: statusProjection.value, +})) + +const workspaceStepCards = computed(() => buildWorkspaceStepCards(workspaceOverviewInput.value)) +const suggestedNextStep = computed(() => buildSuggestedNextStep(workspaceOverviewInput.value)) + +const graphHeaderTitle = computed(() => + kgIdentity.value?.name ?? 'Knowledge Graph Manage Workspace', +) + const modeLabel = computed(() => statusProjection.value?.workspace_mode === 'extraction_operations' ? 'Extraction Operations' @@ -151,6 +191,45 @@ const sessionActivityLines = computed(() => { return candidate.filter((line): line is string => typeof line === 'string' && line.trim().length > 0) }) +async function loadKgIdentity() { + if (!hasTenant.value || !kgId.value) return + try { + kgIdentity.value = await apiFetch<KnowledgeGraphIdentity>( + `/management/knowledge-graphs/${kgId.value}`, + ) + } catch (err) { + kgIdentity.value = { id: kgId.value, name: kgId.value } + toast.error('Failed to load knowledge graph identity', { + description: extractErrorMessage(err), + }) + } +} + +async function loadOverviewMetrics() { + if (!hasTenant.value || !kgId.value) return + try { + const dataSources = await apiFetch<DataSourceRef[]>( + `/management/knowledge-graphs/${kgId.value}/data-sources`, + ) + dataSourceCount.value = dataSources.length + maintenanceReadyCount.value = dataSources.filter((ds) => { + if (!ds.last_extraction_baseline_commit || !ds.tracked_branch_head_commit) return false + return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit + }).length + } catch { + dataSourceCount.value = 0 + maintenanceReadyCount.value = 0 + } +} + +function openWorkspaceStep(stepId: WorkspaceStepId) { + navigateTo(resolveStepDestination(kgId.value, stepId)) +} + +function returnToWorkspaceOverview() { + navigateTo(buildManageStepUrl(kgId.value)) +} + async function loadWorkspaceStatus() { if (!hasTenant.value || !kgId.value) return loading.value = true @@ -270,6 +349,7 @@ async function transitionToExtraction() { } async function clearChat() { + // Clear chat resets the active extraction session for this knowledge graph. if (!kgId.value) return clearingChat.value = true try { @@ -288,14 +368,21 @@ async function clearChat() { } onMounted(() => { + loadKgIdentity() loadWorkspaceStatus() + loadOverviewMetrics() loadMutationLogRuns() }) watch(tenantVersion, () => { + kgIdentity.value = null statusProjection.value = null extractionSession.value = null + dataSourceCount.value = 0 + maintenanceReadyCount.value = 0 + loadKgIdentity() loadWorkspaceStatus() + loadOverviewMetrics() loadMutationLogRuns() }) @@ -314,16 +401,25 @@ watch( <div class="flex items-center justify-between"> <div class="space-y-1"> <div class="flex items-center gap-2"> - <h1 class="text-2xl font-semibold tracking-tight">Knowledge Graph Manage Workspace</h1> - <Badge variant="secondary">{{ modeLabel }}</Badge> + <h1 class="text-2xl font-semibold tracking-tight">{{ graphHeaderTitle }}</h1> + <Badge v-if="!showOverview" variant="secondary">{{ modeLabel }}</Badge> </div> <p class="text-sm text-muted-foreground"> - Validate readiness and move from schema bootstrap to extraction operations. + <template v-if="showOverview"> + Project workspace for knowledge graph {{ kgId }}. + </template> + <template v-else> + Validate readiness and move from schema bootstrap to extraction operations. + </template> </p> </div> - <Button variant="outline" size="sm" @click="navigateTo('/knowledge-graphs')"> + <Button + variant="outline" + size="sm" + @click="showOverview ? navigateTo('/knowledge-graphs') : returnToWorkspaceOverview()" + > <ArrowLeft class="mr-1.5 size-3.5" /> - Back to Knowledge Graphs + {{ showOverview ? 'Back to Knowledge Graphs' : 'Back to workspace overview' }} </Button> </div> @@ -339,6 +435,164 @@ watch( </div> <template v-else-if="statusProjection"> + <section v-if="showOverview" class="space-y-6"> + <div> + <h2 class="text-lg font-semibold tracking-tight">Project workspace</h2> + <p class="text-sm text-muted-foreground"> + Choose a step to continue work on this knowledge graph without re-selecting context. + </p> + </div> + + <Card class="border-primary/30 bg-primary/5"> + <CardHeader class="pb-3"> + <CardTitle class="text-base">Suggested next step</CardTitle> + <CardDescription>{{ suggestedNextStep.description }}</CardDescription> + </CardHeader> + <CardContent> + <Button @click="openWorkspaceStep(suggestedNextStep.stepId)"> + {{ suggestedNextStep.actionLabel }} {{ suggestedNextStep.title }} + </Button> + </CardContent> + </Card> + + <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> + <!-- Step cards: Data Sources, Graph Management, MutationLogs, Maintain --> + <Card + v-for="card in workspaceStepCards" + :key="card.id" + class="flex flex-col" + :class="stepStatusTintClass(card.status)" + > + <CardHeader class="pb-3"> + <div class="flex items-center justify-between gap-2"> + <CardTitle class="text-base">{{ card.title }}</CardTitle> + <Badge variant="outline">{{ card.status }}</Badge> + </div> + <CardDescription>{{ card.statusDetail }}</CardDescription> + </CardHeader> + <CardContent class="mt-auto"> + <Button + class="w-full" + variant="outline" + @click="openWorkspaceStep(card.id)" + > + {{ card.actionLabel }} + </Button> + </CardContent> + </Card> + </div> + </section> + + <section v-else-if="activeStep === 'mutation-logs'" class="space-y-4"> + <Card> + <CardHeader> + <CardTitle class="text-base">MutationLogs</CardTitle> + <CardDescription> + Knowledge-graph scoped mutation runs with per-entry operation previews and run metrics. + </CardDescription> + </CardHeader> + <CardContent class="grid gap-3 xl:grid-cols-[280px_1fr]"> + <div class="rounded border"> + <div class="flex items-center justify-between border-b px-3 py-2"> + <p class="text-xs font-medium text-muted-foreground">Runs</p> + <Button size="sm" variant="ghost" class="h-6 px-2 text-[10px]" @click="loadMutationLogRuns"> + Refresh + </Button> + </div> + <div v-if="mutationLogLoading" class="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground"> + <Loader2 class="size-3.5 animate-spin" /> + Loading mutation runs... + </div> + <div v-else-if="mutationLogRuns.length === 0" class="px-3 py-4 text-xs text-muted-foreground"> + No mutation log runs found for this knowledge graph yet. + </div> + <div v-else class="max-h-64 overflow-auto p-2 space-y-1.5"> + <button + v-for="run in mutationLogRuns" + :key="run.id" + class="w-full rounded border px-2 py-1.5 text-left text-xs transition-colors" + :class="selectedMutationLogRunId === run.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'" + @click="selectedMutationLogRunId = run.id" + > + <p class="font-medium truncate">{{ run.data_source_name }}</p> + <p class="text-muted-foreground truncate">{{ new Date(run.started_at).toLocaleString() }}</p> + <div class="mt-1 flex items-center justify-between"> + <Badge variant="outline" class="text-[10px]">{{ run.status }}</Badge> + <span class="font-mono text-[10px] text-muted-foreground">{{ run.mutation_log_id }}</span> + </div> + </button> + </div> + </div> + + <div v-if="selectedMutationLogRun" class="space-y-3 rounded border p-3"> + <div class="flex flex-wrap items-center gap-2"> + <Badge>{{ selectedMutationLogRun.status }}</Badge> + <p class="text-xs text-muted-foreground"> + Data source: + <span class="font-medium text-foreground">{{ selectedMutationLogRun.data_source_name }}</span> + </p> + </div> + <div class="grid gap-2 sm:grid-cols-2"> + <div class="rounded border px-3 py-2 text-xs"> + <p class="text-muted-foreground">MutationLog</p> + <p class="mt-1 font-mono break-all">{{ selectedMutationLogRun.mutation_log_id }}</p> + </div> + <div class="rounded border px-3 py-2 text-xs"> + <p class="text-muted-foreground">Session</p> + <p class="mt-1 font-mono break-all">{{ selectedMutationLogRun.session_id ?? 'None' }}</p> + </div> + <div class="rounded border px-3 py-2 text-xs"> + <p class="text-muted-foreground">Started</p> + <p class="mt-1">{{ new Date(selectedMutationLogRun.started_at).toLocaleString() }}</p> + </div> + <div class="rounded border px-3 py-2 text-xs"> + <p class="text-muted-foreground">Completed</p> + <p class="mt-1"> + {{ selectedMutationLogRun.completed_at ? new Date(selectedMutationLogRun.completed_at).toLocaleString() : 'In progress' }} + </p> + </div> + </div> + <div class="grid gap-2 sm:grid-cols-2"> + <div class="rounded border px-3 py-2 text-xs"> + <p class="text-muted-foreground flex items-center gap-1.5"> + <Coins class="size-3.5" /> + Token usage + </p> + <p class="mt-1 font-medium">{{ (selectedMutationLogRun.token_usage_total ?? 0).toLocaleString() }}</p> + </div> + <div class="rounded border px-3 py-2 text-xs"> + <p class="text-muted-foreground flex items-center gap-1.5"> + <DollarSign class="size-3.5" /> + Cost (USD) + </p> + <p class="mt-1 font-medium">${{ (selectedMutationLogRun.cost_total_usd ?? 0).toFixed(2) }}</p> + </div> + </div> + <div class="rounded border p-3"> + <p class="mb-2 text-xs font-medium text-muted-foreground">Per-entry operation previews</p> + <div v-if="Object.keys(selectedMutationLogRun.operation_counts).length === 0" class="text-xs text-muted-foreground"> + No operation class counts recorded for this run. + </div> + <div v-else class="space-y-1.5"> + <div + v-for="([opClass, count]) in Object.entries(selectedMutationLogRun.operation_counts)" + :key="opClass" + class="flex items-center justify-between rounded border px-2 py-1.5 text-xs" + > + <span class="font-mono">{{ opClass }}</span> + <Badge variant="secondary">{{ count }}</Badge> + </div> + </div> + </div> + </div> + <div v-else class="rounded border border-dashed p-6 text-sm text-muted-foreground"> + Select a mutation run to view summary and per-entry previews. + </div> + </CardContent> + </Card> + </section> + + <section v-else class="space-y-6"> <Card> <CardHeader> <CardTitle class="text-base">Mode & Transition Controls</CardTitle> @@ -657,6 +911,7 @@ watch( </CardContent> </Card> </div> + </section> </template> </div> </template> diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 7a850813b..65555968a 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -1,16 +1,47 @@ import { describe, it, expect } from 'vitest' import { readFileSync } from 'fs' import { resolve } from 'path' +import { + WORKSPACE_STEP_ORDER, + WORKSPACE_STEP_TITLES, + buildDataSourcesStepUrl, + buildMaintainStepUrl, + buildManageStepUrl, + buildSuggestedNextStep, + buildWorkspaceStepCards, + isMaintenanceReady, + resolveStepDestination, + stepStatusTintClass, +} from '../utils/kgManageWorkspace' const manageWorkspaceVue = readFileSync( resolve(__dirname, '../pages/knowledge-graphs/[kgId]/manage.vue'), 'utf-8', ) +const kgIndexVue = readFileSync( + resolve(__dirname, '../pages/knowledge-graphs/index.vue'), + 'utf-8', +) +const dataSourcesVue = readFileSync( + resolve(__dirname, '../pages/data-sources/index.vue'), + 'utf-8', +) const sharedConversationPanelVue = readFileSync( resolve(__dirname, '../components/extraction/SharedConversationPanel.vue'), 'utf-8', ) +const baseWorkspaceStatus = { + workspace_mode: 'schema_bootstrap' as const, + transition_eligible: false, + readiness: { + has_minimum_entity_types: false, + has_minimum_relationship_types: false, + prepopulated_types_ready: false, + blocking_reasons: ['Missing entity types'], + }, +} + describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { it('loads workspace status projection from management API', () => { expect(manageWorkspaceVue).toContain('/workspace-status') @@ -109,6 +140,167 @@ describe('Knowledge Graph Manage Workspace - bootstrap readiness guidance', () = }) }) +describe('KG-MANAGE-001 - manage entry navigation', () => { + it('routes Manage action to graph-scoped manage workspace', () => { + expect(kgIndexVue).toContain('navigateTo(`/knowledge-graphs/${kg.id}/manage`)') + }) + + it('loads graph identity for manage header and back action', () => { + expect(manageWorkspaceVue).toContain('/management/knowledge-graphs/${kgId.value}') + expect(manageWorkspaceVue).toContain('loadKgIdentity') + expect(manageWorkspaceVue).toContain('Back to Knowledge Graphs') + }) +}) + +describe('KG-MANAGE-002 - workspace step card set', () => { + it('renders Project workspace section with exactly four step cards', () => { + expect(manageWorkspaceVue).toContain('Project workspace') + expect(manageWorkspaceVue).toContain('workspaceStepCards') + for (const stepId of WORKSPACE_STEP_ORDER) { + expect(manageWorkspaceVue).toContain(WORKSPACE_STEP_TITLES[stepId]) + } + }) + + it('buildWorkspaceStepCards returns the canonical four-card set', () => { + const cards = buildWorkspaceStepCards({ + kgId: 'kg-1', + dataSourceCount: 1, + maintenanceReadyCount: 0, + mutationLogRunCount: 0, + workspaceStatus: baseWorkspaceStatus, + }) + + expect(cards.map((card) => card.title)).toEqual([ + 'Data Sources', + 'Graph Management', + 'MutationLogs', + 'Maintain', + ]) + }) +}) + +describe('KG-MANAGE-003 - suggested next step callout', () => { + it('renders Suggested next step callout above the card grid', () => { + expect(manageWorkspaceVue).toContain('Suggested next step') + expect(manageWorkspaceVue).toContain('suggestedNextStep') + expect(manageWorkspaceVue).toContain('openWorkspaceStep') + }) + + it('prioritizes data sources when no sources are connected', () => { + const next = buildSuggestedNextStep({ + kgId: 'kg-1', + dataSourceCount: 0, + maintenanceReadyCount: 0, + mutationLogRunCount: 0, + workspaceStatus: baseWorkspaceStatus, + }) + + expect(next.stepId).toBe('data-sources') + expect(next.actionLabel).toBe('Open') + }) + + it('uses Run action when maintenance is ready', () => { + const next = buildSuggestedNextStep({ + kgId: 'kg-1', + dataSourceCount: 2, + maintenanceReadyCount: 1, + mutationLogRunCount: 3, + workspaceStatus: { + workspace_mode: 'extraction_operations', + transition_eligible: true, + readiness: { + has_minimum_entity_types: true, + has_minimum_relationship_types: true, + prepopulated_types_ready: true, + blocking_reasons: [], + }, + }, + }) + + expect(next.stepId).toBe('maintain') + expect(next.actionLabel).toBe('Run') + }) +}) + +describe('KG-MANAGE-004 - step card status semantics', () => { + it('renders status label, tint, detail text, and primary action per card', () => { + expect(manageWorkspaceVue).toContain('stepStatusTintClass') + expect(manageWorkspaceVue).toContain('card.status') + expect(manageWorkspaceVue).toContain('card.statusDetail') + expect(manageWorkspaceVue).toContain('card.actionLabel') + }) + + it('maps each status label to a tint class', () => { + expect(stepStatusTintClass('ready')).toContain('emerald') + expect(stepStatusTintClass('in_progress')).toContain('blue') + expect(stepStatusTintClass('needs_attention')).toContain('amber') + expect(stepStatusTintClass('blocked')).toContain('destructive') + }) + + it('uses Open, Revisit, or Run action labels on cards', () => { + const cards = buildWorkspaceStepCards({ + kgId: 'kg-1', + dataSourceCount: 2, + maintenanceReadyCount: 1, + mutationLogRunCount: 4, + workspaceStatus: { + workspace_mode: 'extraction_operations', + transition_eligible: true, + readiness: { + has_minimum_entity_types: true, + has_minimum_relationship_types: true, + prepopulated_types_ready: true, + blocking_reasons: [], + }, + }, + }) + + expect(cards.every((card) => ['Open', 'Revisit', 'Run'].includes(card.actionLabel))).toBe(true) + expect(cards.find((card) => card.id === 'maintain')?.actionLabel).toBe('Run') + }) +}) + +describe('KG-MANAGE-005 - graph-scoped data sources step', () => { + it('routes Data Sources step with kg_id and manage return context', () => { + expect(manageWorkspaceVue).toContain('buildDataSourcesStepUrl') + expect(buildDataSourcesStepUrl('kg-abc')).toBe('/data-sources?kg_id=kg-abc&from=manage') + }) + + it('data-sources page preserves manage return path without auto-opening wizard', () => { + expect(dataSourcesVue).toContain('from=manage') + expect(dataSourcesVue).toContain('scopedKnowledgeGraphId') + expect(dataSourcesVue).toContain('Back to workspace overview') + }) +}) + +describe('KG-MANAGE-015 - graph-scoped maintain step and round trip', () => { + it('routes Maintain step with graph scope and maintenance focus', () => { + expect(manageWorkspaceVue).toContain('buildMaintainStepUrl') + expect(buildMaintainStepUrl('kg-abc')).toBe( + '/data-sources?kg_id=kg-abc&from=manage&focus=maintain', + ) + }) + + it('returns to manage overview from in-page steps', () => { + expect(manageWorkspaceVue).toContain('returnToWorkspaceOverview') + expect(buildManageStepUrl('kg-abc')).toBe('/knowledge-graphs/kg-abc/manage') + expect(resolveStepDestination('kg-abc', 'graph-management')).toBe( + '/knowledge-graphs/kg-abc/manage?step=graph-management', + ) + }) + + it('detects maintenance readiness from commit diff semantics', () => { + expect(isMaintenanceReady({ + last_extraction_baseline_commit: 'abc', + tracked_branch_head_commit: 'def', + })).toBe(true) + expect(isMaintenanceReady({ + last_extraction_baseline_commit: 'abc', + tracked_branch_head_commit: 'abc', + })).toBe(false) + }) +}) + describe('Shared conversation panel - extraction UX contract', () => { it('renders resume-session action and explicit server-side persistence note', () => { expect(sharedConversationPanelVue).toContain('Resume session') diff --git a/src/dev-ui/app/utils/kgManageWorkspace.ts b/src/dev-ui/app/utils/kgManageWorkspace.ts new file mode 100644 index 000000000..7bec05d4c --- /dev/null +++ b/src/dev-ui/app/utils/kgManageWorkspace.ts @@ -0,0 +1,319 @@ +export type WorkspaceStepId = 'data-sources' | 'graph-management' | 'mutation-logs' | 'maintain' + +export type StepStatusLabel = 'ready' | 'in_progress' | 'needs_attention' | 'blocked' + +export type StepActionLabel = 'Open' | 'Revisit' | 'Run' + +export const WORKSPACE_STEP_TITLES: Record<WorkspaceStepId, string> = { + 'data-sources': 'Data Sources', + 'graph-management': 'Graph Management', + 'mutation-logs': 'MutationLogs', + maintain: 'Maintain', +} + +export const WORKSPACE_STEP_ORDER: WorkspaceStepId[] = [ + 'data-sources', + 'graph-management', + 'mutation-logs', + 'maintain', +] + +export interface WorkspaceReadinessSnapshot { + has_minimum_entity_types: boolean + has_minimum_relationship_types: boolean + prepopulated_types_ready: boolean + blocking_reasons: string[] +} + +export interface WorkspaceStatusSnapshot { + workspace_mode: 'schema_bootstrap' | 'extraction_operations' + transition_eligible: boolean + readiness: WorkspaceReadinessSnapshot +} + +export interface WorkspaceOverviewInputs { + kgId: string + dataSourceCount: number + maintenanceReadyCount: number + mutationLogRunCount: number + workspaceStatus: WorkspaceStatusSnapshot | null +} + +export interface WorkspaceStepCardView { + id: WorkspaceStepId + title: string + status: StepStatusLabel + statusDetail: string + actionLabel: StepActionLabel +} + +export interface SuggestedNextStepView { + stepId: WorkspaceStepId + title: string + description: string + actionLabel: StepActionLabel +} + +export function isMaintenanceReady(ds: { + last_extraction_baseline_commit?: string | null + tracked_branch_head_commit?: string | null +}): boolean { + if (!ds.last_extraction_baseline_commit || !ds.tracked_branch_head_commit) return false + return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit +} + +export function buildDataSourcesStepUrl(kgId: string): string { + return `/data-sources?kg_id=${encodeURIComponent(kgId)}&from=manage` +} + +export function buildMaintainStepUrl(kgId: string): string { + return `/data-sources?kg_id=${encodeURIComponent(kgId)}&from=manage&focus=maintain` +} + +export function buildManageStepUrl(kgId: string, step?: WorkspaceStepId): string { + if (!step) { + return `/knowledge-graphs/${encodeURIComponent(kgId)}/manage` + } + return `/knowledge-graphs/${encodeURIComponent(kgId)}/manage?step=${step}` +} + +export function parseManageStepQuery(step: unknown): WorkspaceStepId | null { + if (step === 'graph-management' || step === 'mutation-logs') { + return step + } + return null +} + +export function stepStatusTintClass(status: StepStatusLabel): string { + switch (status) { + case 'ready': + return 'border-emerald-500/40 bg-emerald-50/30 dark:bg-emerald-950/20' + case 'in_progress': + return 'border-blue-500/40 bg-blue-50/30 dark:bg-blue-950/20' + case 'needs_attention': + return 'border-amber-500/40 bg-amber-50/30 dark:bg-amber-950/20' + case 'blocked': + return 'border-destructive/50 bg-destructive/5' + } +} + +function buildDataSourcesCard(input: WorkspaceOverviewInputs): WorkspaceStepCardView { + if (input.dataSourceCount === 0) { + return { + id: 'data-sources', + title: WORKSPACE_STEP_TITLES['data-sources'], + status: 'needs_attention', + statusDetail: 'No data sources connected yet.', + actionLabel: 'Open', + } + } + + return { + id: 'data-sources', + title: WORKSPACE_STEP_TITLES['data-sources'], + status: 'ready', + statusDetail: `${input.dataSourceCount} data source${input.dataSourceCount === 1 ? '' : 's'} connected.`, + actionLabel: 'Revisit', + } +} + +function buildGraphManagementCard(input: WorkspaceOverviewInputs): WorkspaceStepCardView { + const status = input.workspaceStatus + + if (!status) { + return { + id: 'graph-management', + title: WORKSPACE_STEP_TITLES['graph-management'], + status: 'in_progress', + statusDetail: 'Loading workspace readiness signals.', + actionLabel: 'Open', + } + } + + if (status.workspace_mode === 'schema_bootstrap') { + if (status.readiness.blocking_reasons.length > 0) { + return { + id: 'graph-management', + title: WORKSPACE_STEP_TITLES['graph-management'], + status: 'needs_attention', + statusDetail: `${status.readiness.blocking_reasons.length} blocking reason${status.readiness.blocking_reasons.length === 1 ? '' : 's'} before extraction.`, + actionLabel: 'Open', + } + } + + if (status.transition_eligible) { + return { + id: 'graph-management', + title: WORKSPACE_STEP_TITLES['graph-management'], + status: 'ready', + statusDetail: 'Schema bootstrap is ready to transition to extraction.', + actionLabel: 'Run', + } + } + + return { + id: 'graph-management', + title: WORKSPACE_STEP_TITLES['graph-management'], + status: 'in_progress', + statusDetail: 'Continue schema bootstrap and validation work.', + actionLabel: 'Open', + } + } + + return { + id: 'graph-management', + title: WORKSPACE_STEP_TITLES['graph-management'], + status: 'ready', + statusDetail: 'Extraction operations mode is active.', + actionLabel: 'Revisit', + } +} + +function buildMutationLogsCard(input: WorkspaceOverviewInputs): WorkspaceStepCardView { + if (input.dataSourceCount === 0) { + return { + id: 'mutation-logs', + title: WORKSPACE_STEP_TITLES['mutation-logs'], + status: 'blocked', + statusDetail: 'Connect a data source before reviewing mutation runs.', + actionLabel: 'Open', + } + } + + if (input.mutationLogRunCount === 0) { + return { + id: 'mutation-logs', + title: WORKSPACE_STEP_TITLES['mutation-logs'], + status: input.workspaceStatus?.workspace_mode === 'extraction_operations' + ? 'needs_attention' + : 'ready', + statusDetail: 'No mutation log runs recorded for this graph yet.', + actionLabel: 'Open', + } + } + + return { + id: 'mutation-logs', + title: WORKSPACE_STEP_TITLES['mutation-logs'], + status: 'ready', + statusDetail: `${input.mutationLogRunCount} mutation run${input.mutationLogRunCount === 1 ? '' : 's'} available.`, + actionLabel: 'Revisit', + } +} + +function buildMaintainCard(input: WorkspaceOverviewInputs): WorkspaceStepCardView { + if (input.dataSourceCount === 0) { + return { + id: 'maintain', + title: WORKSPACE_STEP_TITLES.maintain, + status: 'blocked', + statusDetail: 'Add a data source before maintenance can run.', + actionLabel: 'Open', + } + } + + if (input.maintenanceReadyCount > 0) { + return { + id: 'maintain', + title: WORKSPACE_STEP_TITLES.maintain, + status: 'needs_attention', + statusDetail: `${input.maintenanceReadyCount} source${input.maintenanceReadyCount === 1 ? '' : 's'} have new commits ready for maintenance.`, + actionLabel: 'Run', + } + } + + return { + id: 'maintain', + title: WORKSPACE_STEP_TITLES.maintain, + status: 'ready', + statusDetail: 'All tracked sources are up to date.', + actionLabel: 'Revisit', + } +} + +export function buildWorkspaceStepCards(input: WorkspaceOverviewInputs): WorkspaceStepCardView[] { + return [ + buildDataSourcesCard(input), + buildGraphManagementCard(input), + buildMutationLogsCard(input), + buildMaintainCard(input), + ] +} + +export function buildSuggestedNextStep(input: WorkspaceOverviewInputs): SuggestedNextStepView { + const cards = buildWorkspaceStepCards(input) + + if (input.dataSourceCount === 0) { + const card = cards.find((item) => item.id === 'data-sources')! + return { + stepId: 'data-sources', + title: card.title, + description: 'Connect a data source to start schema bootstrap and extraction.', + actionLabel: card.actionLabel, + } + } + + const maintainCard = cards.find((item) => item.id === 'maintain')! + if (maintainCard.status === 'needs_attention' && maintainCard.actionLabel === 'Run') { + return { + stepId: 'maintain', + title: maintainCard.title, + description: maintainCard.statusDetail, + actionLabel: 'Run', + } + } + + const graphCard = cards.find((item) => item.id === 'graph-management')! + if ( + input.workspaceStatus?.workspace_mode === 'schema_bootstrap' + && input.workspaceStatus.transition_eligible + ) { + return { + stepId: 'graph-management', + title: graphCard.title, + description: 'Validate readiness and transition into extraction operations.', + actionLabel: 'Run', + } + } + + if ( + graphCard.status === 'needs_attention' + || graphCard.status === 'in_progress' + ) { + return { + stepId: 'graph-management', + title: graphCard.title, + description: graphCard.statusDetail, + actionLabel: graphCard.actionLabel, + } + } + + const mutationCard = cards.find((item) => item.id === 'mutation-logs')! + if (mutationCard.status === 'needs_attention') { + return { + stepId: 'mutation-logs', + title: mutationCard.title, + description: mutationCard.statusDetail, + actionLabel: mutationCard.actionLabel, + } + } + + return { + stepId: 'graph-management', + title: graphCard.title, + description: graphCard.statusDetail, + actionLabel: 'Revisit', + } +} + +export function resolveStepDestination(kgId: string, stepId: WorkspaceStepId): string { + switch (stepId) { + case 'data-sources': + return buildDataSourcesStepUrl(kgId) + case 'maintain': + return buildMaintainStepUrl(kgId) + case 'graph-management': + case 'mutation-logs': + return buildManageStepUrl(kgId, stepId) + } +} From b5af3b1b9ca934325619b175cf9fd73d3ae26dfc Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:20:31 -0400 Subject: [PATCH 42/80] feat(extraction): wire runtime credential injection for extraction workers (#730) Issue scoped credentials through the JobPackageProduced handler, enforce launcher scope checks, redact secrets in failure telemetry, and add tests for expiration and least-privilege enforcement. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../infrastructure/event_handler.py | 91 +++++++- src/api/extraction/ports/__init__.py | 2 + src/api/extraction/ports/runtime.py | 10 + src/api/extraction/ports/services.py | 7 +- src/api/main.py | 30 ++- .../test_workload_credential_injection.py | 215 ++++++++++++++++++ .../test_extraction_event_handler.py | 118 +++++++++- .../infrastructure/test_workload_runtime.py | 21 ++ 8 files changed, 487 insertions(+), 7 deletions(-) create mode 100644 src/api/tests/integration/extraction/test_workload_credential_injection.py diff --git a/src/api/extraction/infrastructure/event_handler.py b/src/api/extraction/infrastructure/event_handler.py index 32cceb1ee..a6303d386 100644 --- a/src/api/extraction/infrastructure/event_handler.py +++ b/src/api/extraction/infrastructure/event_handler.py @@ -10,9 +10,16 @@ from __future__ import annotations +import re from datetime import UTC, datetime from typing import TYPE_CHECKING, Any +from extraction.ports.runtime import ( + EphemeralWorkerLaunchRequest, + IEphemeralExtractionWorkerLauncher, + IWorkloadCredentialIssuer, + ScopedWorkloadCredentials, +) from extraction.ports.services import IExtractionService if TYPE_CHECKING: @@ -23,9 +30,10 @@ class ExtractionEventHandler: """Handles JobPackageProduced events by running the extraction pipeline. When a JobPackageProduced event is processed from the outbox, this handler: - 1. Delegates to IExtractionService.run() to extract entities and relationships - 2. On success: appends MutationLogProduced to the outbox - 3. On failure: appends ExtractionFailed to the outbox + 1. Issues short-lived scoped credentials and launches an ephemeral worker + 2. Delegates to IExtractionService.run() to extract entities and relationships + 3. On success: appends MutationLogProduced to the outbox + 4. On failure: appends ExtractionFailed to the outbox This handler is the entry point for the Extraction bounded context in the sync lifecycle. It creates the linkage between the Ingestion context @@ -41,6 +49,9 @@ def __init__( extraction_service: IExtractionService, outbox: "IOutboxRepository", runtime_context_builder: Any, + *, + credential_issuer: IWorkloadCredentialIssuer | None = None, + worker_launcher: IEphemeralExtractionWorkerLauncher | None = None, ) -> None: """Initialize the extraction event handler. @@ -48,19 +59,58 @@ def __init__( extraction_service: Service that runs the AI extraction pipeline outbox: Repository for writing output events (MutationLogProduced / ExtractionFailed) + runtime_context_builder: Resolves runtime paths for the workload + credential_issuer: Optional issuer for runtime-only workload credentials + worker_launcher: Optional launcher that enforces credential scope """ + if (credential_issuer is None) ^ (worker_launcher is None): + raise ValueError( + "credential_issuer and worker_launcher must be configured together" + ) + self._extraction_service = extraction_service self._outbox = outbox self._runtime_context_builder = runtime_context_builder + self._credential_issuer = credential_issuer + self._worker_launcher = worker_launcher def supported_event_types(self) -> frozenset[str]: """Return event types handled by this handler.""" return frozenset({"JobPackageProduced"}) + @staticmethod + def _redact_sensitive_error(message: str) -> str: + """Redact token-like secrets from error strings before persistence.""" + patterns = ( + re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"), + re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]{16,}\b"), + re.compile( + r"(?i)\b(token|access_token|password|api[_-]?key)\b\s*[:=]\s*['\"]?[^\s,'\"]+" + ), + ) + redacted = message + for pattern in patterns: + redacted = pattern.sub("***REDACTED***", redacted) + return redacted + + @classmethod + def _sanitize_failure_error( + cls, + exc: Exception, + *, + workload_credentials: ScopedWorkloadCredentials | None, + ) -> str: + message = str(exc) + if workload_credentials is not None and workload_credentials.token: + message = message.replace(workload_credentials.token, "***REDACTED***") + return cls._redact_sensitive_error(message) + async def handle( self, event_type: str, payload: dict[str, Any], + *, + tenant_id: str | None = None, ) -> None: """Process a JobPackageProduced event by running the extraction pipeline. @@ -71,6 +121,7 @@ async def handle( - data_source_id: The data source being extracted - knowledge_graph_id: The target knowledge graph - job_package_id: The JobPackage to process + tenant_id: Tenant scope used for runtime credential issuance """ if event_type != "JobPackageProduced": return @@ -81,7 +132,32 @@ async def handle( job_package_id = payload["job_package_id"] now = datetime.now(UTC) + workload_credentials: ScopedWorkloadCredentials | None = None + worker_id: str | None = None + try: + if self._credential_issuer is not None and self._worker_launcher is not None: + if not tenant_id: + raise ValueError( + "tenant_id is required for scoped workload credential injection" + ) + + workload_credentials = self._credential_issuer.issue( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + ) + launch_result = self._worker_launcher.launch( + request=EphemeralWorkerLaunchRequest( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + session_id=f"sync:{sync_run_id}", + sync_run_id=sync_run_id, + job_package_id=job_package_id, + ), + credentials=workload_credentials, + ) + worker_id = launch_result.worker_id + runtime_context = self._runtime_context_builder.build( sync_run_id=sync_run_id, job_package_id=job_package_id, @@ -92,6 +168,7 @@ async def handle( knowledge_graph_id=knowledge_graph_id, job_package_id=job_package_id, runtime_context=runtime_context, + workload_credentials=workload_credentials, ) except Exception as exc: await self._outbox.append( @@ -99,7 +176,10 @@ async def handle( payload={ "sync_run_id": sync_run_id, "data_source_id": data_source_id, - "error": str(exc), + "error": self._sanitize_failure_error( + exc, + workload_credentials=workload_credentials, + ), "occurred_at": now.isoformat(), }, occurred_at=now, @@ -107,6 +187,9 @@ async def handle( aggregate_id=sync_run_id, ) return + finally: + if worker_id is not None and self._worker_launcher is not None: + self._worker_launcher.complete_worker(worker_id) # Extraction succeeded — append success event outside the try block so # that an outbox write failure here is not mistaken for an extraction diff --git a/src/api/extraction/ports/__init__.py b/src/api/extraction/ports/__init__.py index 2e253a7ea..10262ea8e 100644 --- a/src/api/extraction/ports/__init__.py +++ b/src/api/extraction/ports/__init__.py @@ -9,6 +9,7 @@ EphemeralWorkerLaunchResult, IEphemeralExtractionWorkerLauncher, IStickySessionRuntimeManager, + IWorkloadCredentialIssuer, ScopedWorkloadCredentials, StickySessionRuntimeLease, ) @@ -20,6 +21,7 @@ "IExtractionSkillOverrideRepository", "IStickySessionRuntimeManager", "IEphemeralExtractionWorkerLauncher", + "IWorkloadCredentialIssuer", "StickySessionRuntimeLease", "ScopedWorkloadCredentials", "EphemeralWorkerLaunchRequest", diff --git a/src/api/extraction/ports/runtime.py b/src/api/extraction/ports/runtime.py index 624973ebd..b446abeba 100644 --- a/src/api/extraction/ports/runtime.py +++ b/src/api/extraction/ports/runtime.py @@ -50,6 +50,16 @@ class EphemeralWorkerLaunchResult: credentials_expires_at: datetime +class IWorkloadCredentialIssuer(Protocol): + """Issues short-lived credentials scoped to tenant and knowledge graph.""" + + def issue( + self, *, tenant_id: str, knowledge_graph_id: str + ) -> ScopedWorkloadCredentials: + """Return runtime-only credentials for one extraction workload.""" + ... + + class IStickySessionRuntimeManager(Protocol): """Manages sticky chat runtime containers for active sessions.""" diff --git a/src/api/extraction/ports/services.py b/src/api/extraction/ports/services.py index 7c73d4865..851dfd3bc 100644 --- a/src/api/extraction/ports/services.py +++ b/src/api/extraction/ports/services.py @@ -3,7 +3,10 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from extraction.ports.runtime import ScopedWorkloadCredentials @dataclass(frozen=True) @@ -34,6 +37,7 @@ async def run( knowledge_graph_id: str, job_package_id: str, runtime_context: ExtractionRuntimeContext, + workload_credentials: ScopedWorkloadCredentials | None = None, ) -> str: """Run the AI extraction pipeline for a JobPackage. @@ -44,6 +48,7 @@ async def run( job_package_id: Identifier for the JobPackage to process runtime_context: Resolved runtime context paths for ingestion resources, reconstructed repository files, and skills availability. + workload_credentials: Short-lived runtime credentials injected into the worker Returns: mutation_log_id: Identifier for the produced MutationLog (JSONL) diff --git a/src/api/main.py b/src/api/main.py index 1b0236b2f..fba1699f1 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -311,6 +311,7 @@ async def run( knowledge_graph_id: str, job_package_id: str, runtime_context: Any, + workload_credentials: Any = None, ) -> str: raise NotImplementedError( "AI extraction pipeline is not yet implemented. " @@ -336,14 +337,25 @@ def supported_event_types(self) -> frozenset[str]: return self._SUPPORTED async def handle(self, event_type: str, payload: dict[str, Any]) -> None: + from datetime import timedelta + from infrastructure.outbox.repository import OutboxRepository from extraction.infrastructure.event_handler import ExtractionEventHandler from extraction.infrastructure.runtime_context_builder import ( FilesystemExtractionRuntimeContextBuilder, ) + from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + ScopedWorkloadCredentialIssuer, + ) + from management.domain.value_objects import KnowledgeGraphId + from management.infrastructure.repositories.knowledge_graph_repository import ( + KnowledgeGraphRepository, + ) async with self._session_factory() as session: outbox = OutboxRepository(session=session) + kg_repo = KnowledgeGraphRepository(session=session, outbox=outbox) runtime_context_builder = FilesystemExtractionRuntimeContextBuilder( work_dir=_JOB_PACKAGE_WORK_DIR, skills_dir=_EXTRACTION_SKILLS_DIR, @@ -352,8 +364,24 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: extraction_service=self._extraction_service, outbox=outbox, runtime_context_builder=runtime_context_builder, + credential_issuer=ScopedWorkloadCredentialIssuer( + default_ttl=timedelta(minutes=15) + ), + worker_launcher=InMemoryEphemeralExtractionWorkerLauncher(), + ) + + tenant_id = str(payload.get("tenant_id", "")) if payload.get("tenant_id") else "" + knowledge_graph_id = str(payload.get("knowledge_graph_id", "")) + if not tenant_id and knowledge_graph_id: + kg = await kg_repo.get_by_id(KnowledgeGraphId(value=knowledge_graph_id)) + if kg is not None: + tenant_id = kg.tenant_id + + await extraction_handler.handle( + event_type, + payload, + tenant_id=tenant_id or None, ) - await extraction_handler.handle(event_type, payload) await session.commit() diff --git a/src/api/tests/integration/extraction/test_workload_credential_injection.py b/src/api/tests/integration/extraction/test_workload_credential_injection.py new file mode 100644 index 000000000..da85476eb --- /dev/null +++ b/src/api/tests/integration/extraction/test_workload_credential_injection.py @@ -0,0 +1,215 @@ +"""Integration tests for extraction workload credential injection.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +import pytest + +from extraction.infrastructure.event_handler import ExtractionEventHandler +from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + ScopedWorkloadCredentialIssuer, +) +from extraction.ports.runtime import ScopedWorkloadCredentials +from extraction.ports.services import ExtractionRuntimeContext + +pytestmark = pytest.mark.integration + + +class _RecordingOutbox: + def __init__(self) -> None: + self.appended: list[dict[str, Any]] = [] + + async def append( + self, + event_type: str, + payload: dict[str, Any], + occurred_at: datetime, + aggregate_type: str, + aggregate_id: str, + ) -> None: + self.appended.append( + { + "event_type": event_type, + "payload": payload, + "occurred_at": occurred_at, + "aggregate_type": aggregate_type, + "aggregate_id": aggregate_id, + } + ) + + async def fetch_unprocessed(self, limit: int = 100) -> list[Any]: + return [] + + async def mark_processed(self, entry_id: UUID) -> None: + pass + + +class _RecordingExtractionService: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def run( + self, + sync_run_id: str, + data_source_id: str, + knowledge_graph_id: str, + job_package_id: str, + runtime_context: ExtractionRuntimeContext, + workload_credentials: ScopedWorkloadCredentials | None = None, + ) -> str: + self.calls.append( + { + "sync_run_id": sync_run_id, + "workload_credentials": workload_credentials, + } + ) + return "mutation-log-integration" + + +class _StaticRuntimeContextBuilder: + def build(self, *, sync_run_id: str, job_package_id: str) -> ExtractionRuntimeContext: + return ExtractionRuntimeContext( + ingestion_context_dir="/tmp/ingestion-context", + repository_files_dir="/tmp/repository-files", + skills_dir="/app/skills", + job_package_archive="/tmp/job-package.zip", + ) + + +def _payload(*, tenant_id: str = "tenant-integration") -> dict[str, Any]: + return { + "sync_run_id": "sync-integration-1", + "data_source_id": "ds-integration-1", + "knowledge_graph_id": "kg-integration-1", + "job_package_id": "pkg-integration-1", + "tenant_id": tenant_id, + "occurred_at": datetime.now(UTC).isoformat(), + } + + +def _handler( + *, + service: _RecordingExtractionService | None = None, + launcher: InMemoryEphemeralExtractionWorkerLauncher | None = None, +) -> tuple[ExtractionEventHandler, _RecordingOutbox, _RecordingExtractionService, InMemoryEphemeralExtractionWorkerLauncher]: + outbox = _RecordingOutbox() + extraction_service = service or _RecordingExtractionService() + worker_launcher = launcher or InMemoryEphemeralExtractionWorkerLauncher() + handler = ExtractionEventHandler( + extraction_service=extraction_service, + outbox=outbox, + runtime_context_builder=_StaticRuntimeContextBuilder(), + credential_issuer=ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)), + worker_launcher=worker_launcher, + ) + return handler, outbox, extraction_service, worker_launcher + + +@pytest.mark.asyncio +async def test_scoped_credentials_are_injected_at_runtime_only() -> None: + handler, outbox, service, launcher = _handler() + + await handler.handle("JobPackageProduced", _payload(), tenant_id="tenant-integration") + + assert len(service.calls) == 1 + credentials = service.calls[0]["workload_credentials"] + assert credentials is not None + assert credentials.scopes == ( + "tenant:tenant-integration", + "knowledge_graph:kg-integration-1", + "workload:extraction", + ) + assert launcher.active_worker_count == 0 + assert len(outbox.appended) == 1 + success = outbox.appended[0] + assert success["event_type"] == "MutationLogProduced" + assert "token" not in success["payload"] + assert credentials.token not in str(success["payload"]) + + +@pytest.mark.asyncio +async def test_rejects_credentials_with_insufficient_scope() -> None: + outbox = _RecordingOutbox() + service = _RecordingExtractionService() + launcher = InMemoryEphemeralExtractionWorkerLauncher() + + class _WrongScopeIssuer: + def issue( + self, *, tenant_id: str, knowledge_graph_id: str + ) -> ScopedWorkloadCredentials: + return ScopedWorkloadCredentials( + token="wrong-scope-token", + expires_at=datetime.now(UTC) + timedelta(minutes=5), + scopes=( + "tenant:tenant-other", + f"knowledge_graph:{knowledge_graph_id}", + "workload:extraction", + ), + ) + + handler = ExtractionEventHandler( + extraction_service=service, + outbox=outbox, + runtime_context_builder=_StaticRuntimeContextBuilder(), + credential_issuer=_WrongScopeIssuer(), + worker_launcher=launcher, + ) + + await handler.handle( + "JobPackageProduced", + _payload(), + tenant_id="tenant-integration", + ) + + assert service.calls == [] + assert len(outbox.appended) == 1 + failure = outbox.appended[0] + assert failure["event_type"] == "ExtractionFailed" + assert "scope" in failure["payload"]["error"].lower() + assert "wrong-scope-token" not in failure["payload"]["error"] + + +@pytest.mark.asyncio +async def test_rejects_expired_credentials() -> None: + outbox = _RecordingOutbox() + service = _RecordingExtractionService() + launcher = InMemoryEphemeralExtractionWorkerLauncher() + + class _ExpiredIssuer: + def issue( + self, *, tenant_id: str, knowledge_graph_id: str + ) -> ScopedWorkloadCredentials: + return ScopedWorkloadCredentials( + token="expired-token-value", + expires_at=datetime.now(UTC) - timedelta(seconds=1), + scopes=( + f"tenant:{tenant_id}", + f"knowledge_graph:{knowledge_graph_id}", + "workload:extraction", + ), + ) + + handler = ExtractionEventHandler( + extraction_service=service, + outbox=outbox, + runtime_context_builder=_StaticRuntimeContextBuilder(), + credential_issuer=_ExpiredIssuer(), + worker_launcher=launcher, + ) + + await handler.handle( + "JobPackageProduced", + _payload(), + tenant_id="tenant-integration", + ) + + assert service.calls == [] + assert len(outbox.appended) == 1 + failure = outbox.appended[0] + assert failure["event_type"] == "ExtractionFailed" + assert "expired" in failure["payload"]["error"].lower() + assert "expired-token-value" not in failure["payload"]["error"] diff --git a/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py b/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py index c04a92096..3b779f5e3 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py +++ b/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py @@ -11,13 +11,18 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any from uuid import UUID import pytest from extraction.infrastructure.event_handler import ExtractionEventHandler +from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + ScopedWorkloadCredentialIssuer, +) +from extraction.ports.runtime import ScopedWorkloadCredentials from extraction.ports.services import ExtractionRuntimeContext @@ -67,6 +72,7 @@ async def run( knowledge_graph_id: str, job_package_id: str, runtime_context: ExtractionRuntimeContext, + workload_credentials: ScopedWorkloadCredentials | None = None, ) -> str: self.calls.append( { @@ -75,6 +81,7 @@ async def run( "knowledge_graph_id": knowledge_graph_id, "job_package_id": job_package_id, "runtime_context": runtime_context, + "workload_credentials": workload_credentials, } ) if self._fail: @@ -263,6 +270,115 @@ async def test_extraction_failed_aggregate_type( assert event["aggregate_id"] == "run-003" +@pytest.mark.asyncio +class TestExtractionEventHandlerCredentialInjection: + """Tests for runtime credential issuance and worker launch enforcement.""" + + async def test_injects_scoped_credentials_before_extraction( + self, + extraction_service: _FakeExtractionService, + outbox: _FakeOutboxRepository, + ) -> None: + handler = ExtractionEventHandler( + extraction_service=extraction_service, + outbox=outbox, + runtime_context_builder=_FakeRuntimeContextBuilder(), + credential_issuer=ScopedWorkloadCredentialIssuer( + default_ttl=timedelta(minutes=10) + ), + worker_launcher=InMemoryEphemeralExtractionWorkerLauncher(), + ) + payload = _job_package_produced_payload(sync_run_id="run-cred") + + await handler.handle("JobPackageProduced", payload, tenant_id="tenant-1") + + assert len(extraction_service.calls) == 1 + credentials = extraction_service.calls[0]["workload_credentials"] + assert credentials is not None + assert credentials.scopes == ( + "tenant:tenant-1", + "knowledge_graph:kg-001", + "workload:extraction", + ) + + async def test_emits_extraction_failed_when_scope_is_invalid( + self, + outbox: _FakeOutboxRepository, + ) -> None: + class _WrongScopeIssuer: + def issue( + self, *, tenant_id: str, knowledge_graph_id: str + ) -> ScopedWorkloadCredentials: + return ScopedWorkloadCredentials( + token="wrong-scope-token", + expires_at=datetime.now(UTC) + timedelta(minutes=5), + scopes=( + "tenant:tenant-other", + f"knowledge_graph:{knowledge_graph_id}", + "workload:extraction", + ), + ) + + handler = ExtractionEventHandler( + extraction_service=_FakeExtractionService(), + outbox=outbox, + runtime_context_builder=_FakeRuntimeContextBuilder(), + credential_issuer=_WrongScopeIssuer(), + worker_launcher=InMemoryEphemeralExtractionWorkerLauncher(), + ) + payload = _job_package_produced_payload(sync_run_id="run-scope-fail") + + await handler.handle( + "JobPackageProduced", + payload, + tenant_id="tenant-1", + ) + + assert len(outbox.appended) == 1 + event = outbox.appended[0] + assert event["event_type"] == "ExtractionFailed" + assert "scope" in event["payload"]["error"].lower() + assert "wrong-scope-token" not in event["payload"]["error"] + + async def test_redacts_secret_material_from_failure_payload( + self, + outbox: _FakeOutboxRepository, + ) -> None: + class _LeakyService(_FakeExtractionService): + async def run( # type: ignore[override] + self, + sync_run_id: str, + data_source_id: str, + knowledge_graph_id: str, + job_package_id: str, + runtime_context: ExtractionRuntimeContext, + workload_credentials: ScopedWorkloadCredentials | None = None, + ) -> str: + raise RuntimeError( + "workload auth failed for token ghp_1234567890abcdef1234567890abcdef1234" + ) + + handler = ExtractionEventHandler( + extraction_service=_LeakyService(), + outbox=outbox, + runtime_context_builder=_FakeRuntimeContextBuilder(), + credential_issuer=ScopedWorkloadCredentialIssuer( + default_ttl=timedelta(minutes=10) + ), + worker_launcher=InMemoryEphemeralExtractionWorkerLauncher(), + ) + payload = _job_package_produced_payload(sync_run_id="run-redact") + + await handler.handle("JobPackageProduced", payload, tenant_id="tenant-1") + + event = outbox.appended[0] + assert event["event_type"] == "ExtractionFailed" + assert "ghp_1234567890abcdef1234567890abcdef1234" not in event["payload"][ + "error" + ] + assert "***REDACTED***" in event["payload"]["error"] + + class _FailingOutboxRepository(_FakeOutboxRepository): """Outbox repository that raises on the first write (simulates outbox failure).""" diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py index d2b8a943c..7e2b4d0d0 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py @@ -13,6 +13,7 @@ ) from extraction.ports.runtime import ( EphemeralWorkerLaunchRequest, + ScopedWorkloadCredentials, ) @@ -79,6 +80,26 @@ def test_cleanup_terminates_expired_sessions(self) -> None: class TestEphemeralWorkerLauncher: + def test_launch_rejects_expired_credentials(self) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + launcher = InMemoryEphemeralExtractionWorkerLauncher() + scoped_credentials = issuer.issue(tenant_id="tenant-1", knowledge_graph_id="kg-1") + expired_credentials = ScopedWorkloadCredentials( + token=scoped_credentials.token, + expires_at=datetime.now(UTC) - timedelta(seconds=1), + scopes=scoped_credentials.scopes, + ) + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + with pytest.raises(ValueError, match="expired"): + launcher.launch(request=request, credentials=expired_credentials) + def test_launch_requires_credentials_scoped_to_request(self) -> None: issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) launcher = InMemoryEphemeralExtractionWorkerLauncher() From 1d9530c6d48e7943c7f2838901ac9bfe4b0e49cd Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:20:35 -0400 Subject: [PATCH 43/80] feat(extraction): add session history views with run-level metrics retention (#731) Expose scoped session history API responses with linked mutation-run metrics, surface archived sessions in KG manage UI after clear chat, and verify retention with unit and integration coverage. Closes #719 Co-authored-by: Cursor <cursoragent@cursor.com> --- .../application/agent_session_service.py | 46 ++++- src/api/extraction/dependencies.py | 3 +- src/api/extraction/domain/value_objects.py | 17 ++ .../infrastructure/repositories/__init__.py | 10 +- .../session_run_metrics_reader.py | 82 ++++++++ src/api/extraction/ports/repositories.py | 13 +- src/api/extraction/presentation/models.py | 78 ++++++- src/api/extraction/presentation/routes.py | 32 +++ .../tests/integration/extraction/__init__.py | 1 + .../tests/integration/extraction/conftest.py | 3 + .../test_session_history_retention.py | 192 ++++++++++++++++++ .../tests/integration/management/conftest.py | 1 + .../test_session_history_service.py | 164 +++++++++++++++ .../extraction/presentation/test_routes.py | 27 +++ .../pages/knowledge-graphs/[kgId]/manage.vue | 112 ++++++++++ .../knowledge-graph-manage-workspace.test.ts | 11 +- 16 files changed, 784 insertions(+), 8 deletions(-) create mode 100644 src/api/extraction/infrastructure/repositories/session_run_metrics_reader.py create mode 100644 src/api/tests/integration/extraction/__init__.py create mode 100644 src/api/tests/integration/extraction/conftest.py create mode 100644 src/api/tests/integration/extraction/test_session_history_retention.py create mode 100644 src/api/tests/unit/extraction/application/test_session_history_service.py diff --git a/src/api/extraction/application/agent_session_service.py b/src/api/extraction/application/agent_session_service.py index b4a4c6a03..fdda14574 100644 --- a/src/api/extraction/application/agent_session_service.py +++ b/src/api/extraction/application/agent_session_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import UTC, datetime from ulid import ULID @@ -11,7 +12,19 @@ ) from extraction.domain.entities.agent_session import ExtractionAgentSession from extraction.domain.value_objects import BootstrapIntakePath, ExtractionSessionMode -from extraction.ports.repositories import IExtractionAgentSessionRepository +from extraction.domain.value_objects import ExtractionSessionRunMetric +from extraction.ports.repositories import ( + IExtractionAgentSessionRepository, + IExtractionSessionRunMetricsReader, +) + + +@dataclass(frozen=True) +class ExtractionSessionHistoryRecord: + """Session history entry with linked run-level metrics.""" + + session: ExtractionAgentSession + run_metrics: list[ExtractionSessionRunMetric] class ExtractionAgentSessionService: @@ -21,9 +34,11 @@ def __init__( self, repository: IExtractionAgentSessionRepository, skill_resolution_service: ExtractionSkillResolutionService | None = None, + run_metrics_reader: IExtractionSessionRunMetricsReader | None = None, ) -> None: self._repository = repository self._skill_resolution_service = skill_resolution_service + self._run_metrics_reader = run_metrics_reader @staticmethod def _build_bootstrap_intake_prompt() -> str: @@ -114,6 +129,35 @@ async def list_sessions( mode=mode, ) + async def list_session_history( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> list[ExtractionSessionHistoryRecord]: + sessions = await self._repository.list_by_scope( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + if not sessions: + return [] + + metrics_by_session: dict[str, list[ExtractionSessionRunMetric]] = {} + if self._run_metrics_reader is not None: + metrics_by_session = await self._run_metrics_reader.find_metrics_by_session_ids( + knowledge_graph_id=knowledge_graph_id, + session_ids=[session.id for session in sessions], + ) + + return [ + ExtractionSessionHistoryRecord( + session=session, + run_metrics=metrics_by_session.get(session.id, []), + ) + for session in sessions + ] + async def archive_session(self, session_id: str) -> ExtractionAgentSession | None: session = await self._repository.get_by_id(session_id) if session is None: diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index e720e81d8..2c6853001 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -11,6 +11,7 @@ ) from extraction.infrastructure.repositories import ( ExtractionAgentSessionRepository, + ExtractionSessionRunMetricsReader, ExtractionSkillOverrideRepository, ) from infrastructure.database.dependencies import get_write_session @@ -26,5 +27,5 @@ def get_extraction_agent_session_service( return ExtractionAgentSessionService( repository=ExtractionAgentSessionRepository(session=session), skill_resolution_service=skill_resolution_service, + run_metrics_reader=ExtractionSessionRunMetricsReader(session=session), ) - diff --git a/src/api/extraction/domain/value_objects.py b/src/api/extraction/domain/value_objects.py index 22ec0470c..906c77c22 100644 --- a/src/api/extraction/domain/value_objects.py +++ b/src/api/extraction/domain/value_objects.py @@ -1,5 +1,9 @@ """Value objects for Extraction session lifecycle.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime from enum import StrEnum @@ -16,3 +20,16 @@ class BootstrapIntakePath(StrEnum): FIRST_PASS_SCHEMA_ATTEMPT = "first_pass_schema_attempt" GUIDED_CO_DESIGN = "guided_co_design" + +@dataclass(frozen=True) +class ExtractionSessionRunMetric: + """Run-level metrics linked to an extraction session.""" + + sync_run_id: str + mutation_log_id: str | None + status: str + started_at: datetime + completed_at: datetime | None = None + token_usage_total: int | None = None + cost_total_usd: float | None = None + operation_counts: dict[str, int] = field(default_factory=dict) diff --git a/src/api/extraction/infrastructure/repositories/__init__.py b/src/api/extraction/infrastructure/repositories/__init__.py index 00204177a..8cf46718b 100644 --- a/src/api/extraction/infrastructure/repositories/__init__.py +++ b/src/api/extraction/infrastructure/repositories/__init__.py @@ -3,9 +3,15 @@ from extraction.infrastructure.repositories.agent_session_repository import ( ExtractionAgentSessionRepository, ) +from extraction.infrastructure.repositories.session_run_metrics_reader import ( + ExtractionSessionRunMetricsReader, +) from extraction.infrastructure.repositories.skill_override_repository import ( ExtractionSkillOverrideRepository, ) -__all__ = ["ExtractionAgentSessionRepository", "ExtractionSkillOverrideRepository"] - +__all__ = [ + "ExtractionAgentSessionRepository", + "ExtractionSessionRunMetricsReader", + "ExtractionSkillOverrideRepository", +] diff --git a/src/api/extraction/infrastructure/repositories/session_run_metrics_reader.py b/src/api/extraction/infrastructure/repositories/session_run_metrics_reader.py new file mode 100644 index 000000000..e6888f7da --- /dev/null +++ b/src/api/extraction/infrastructure/repositories/session_run_metrics_reader.py @@ -0,0 +1,82 @@ +"""PostgreSQL reader for extraction session run metrics.""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from extraction.domain.value_objects import ExtractionSessionRunMetric +from extraction.ports.repositories import IExtractionSessionRunMetricsReader + + +class ExtractionSessionRunMetricsReader(IExtractionSessionRunMetricsReader): + """Resolve sync-run metrics for extraction sessions without Management imports.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def find_metrics_by_session_ids( + self, + *, + knowledge_graph_id: str, + session_ids: list[str], + ) -> dict[str, list[ExtractionSessionRunMetric]]: + if not session_ids: + return {} + + stmt = text( + """ + SELECT + sr.id AS sync_run_id, + sr.status, + sr.started_at, + sr.completed_at, + sr.mutation_log_run + FROM data_source_sync_runs sr + JOIN data_sources ds ON ds.id = sr.data_source_id + WHERE ds.knowledge_graph_id = :knowledge_graph_id + AND sr.mutation_log_run IS NOT NULL + AND sr.mutation_log_run->>'session_id' = ANY(:session_ids) + ORDER BY sr.started_at DESC + """ + ) + result = await self._session.execute( + stmt, + { + "knowledge_graph_id": knowledge_graph_id, + "session_ids": session_ids, + }, + ) + + metrics_by_session: dict[str, list[ExtractionSessionRunMetric]] = { + session_id: [] for session_id in session_ids + } + for row in result.mappings().all(): + payload = row["mutation_log_run"] or {} + session_id = payload.get("session_id") + if session_id not in metrics_by_session: + continue + metrics_by_session[session_id].append( + ExtractionSessionRunMetric( + sync_run_id=row["sync_run_id"], + mutation_log_id=payload.get("mutation_log_id"), + status=row["status"], + started_at=row["started_at"], + completed_at=row["completed_at"], + token_usage_total=( + int(payload["token_usage_total"]) + if payload.get("token_usage_total") is not None + else None + ), + cost_total_usd=( + float(payload["cost_total_usd"]) + if payload.get("cost_total_usd") is not None + else None + ), + operation_counts={ + str(key): int(value) + for key, value in (payload.get("operation_counts") or {}).items() + }, + ) + ) + return metrics_by_session diff --git a/src/api/extraction/ports/repositories.py b/src/api/extraction/ports/repositories.py index c9c9bb597..03c902fed 100644 --- a/src/api/extraction/ports/repositories.py +++ b/src/api/extraction/ports/repositories.py @@ -5,7 +5,7 @@ from typing import Protocol from extraction.domain.entities.agent_session import ExtractionAgentSession -from extraction.domain.value_objects import ExtractionSessionMode +from extraction.domain.value_objects import ExtractionSessionMode, ExtractionSessionRunMetric class IExtractionAgentSessionRepository(Protocol): @@ -30,6 +30,17 @@ async def list_by_scope( ) -> list[ExtractionAgentSession]: ... +class IExtractionSessionRunMetricsReader(Protocol): + """Read-only access to run-level metrics linked to extraction sessions.""" + + async def find_metrics_by_session_ids( + self, + *, + knowledge_graph_id: str, + session_ids: list[str], + ) -> dict[str, list[ExtractionSessionRunMetric]]: ... + + class IExtractionSkillOverrideRepository(Protocol): """Read KG-specific skill override templates.""" diff --git a/src/api/extraction/presentation/models.py b/src/api/extraction/presentation/models.py index 781791ad7..214deb3d4 100644 --- a/src/api/extraction/presentation/models.py +++ b/src/api/extraction/presentation/models.py @@ -7,8 +7,39 @@ from pydantic import BaseModel, Field +from extraction.application.agent_session_service import ExtractionSessionHistoryRecord from extraction.domain.entities.agent_session import ExtractionAgentSession -from extraction.domain.value_objects import BootstrapIntakePath, ExtractionSessionMode +from extraction.domain.value_objects import ( + BootstrapIntakePath, + ExtractionSessionMode, + ExtractionSessionRunMetric, +) + + +class SessionRunMetricResponse(BaseModel): + """Run-level metrics linked to an extraction session.""" + + sync_run_id: str + mutation_log_id: str | None = None + status: str + started_at: datetime + completed_at: datetime | None = None + token_usage_total: int | None = None + cost_total_usd: float | None = None + operation_counts: dict[str, int] = Field(default_factory=dict) + + @classmethod + def from_domain(cls, metric: ExtractionSessionRunMetric) -> "SessionRunMetricResponse": + return cls( + sync_run_id=metric.sync_run_id, + mutation_log_id=metric.mutation_log_id, + status=metric.status, + started_at=metric.started_at, + completed_at=metric.completed_at, + token_usage_total=metric.token_usage_total, + cost_total_usd=metric.cost_total_usd, + operation_counts=dict(metric.operation_counts), + ) class ExtractionSessionResponse(BaseModel): @@ -46,6 +77,50 @@ class ExtractionSessionListResponse(BaseModel): count: int +class ExtractionSessionHistoryItemResponse(BaseModel): + """Historical session summary with linked run metrics.""" + + id: str + user_id: str + knowledge_graph_id: str + mode: ExtractionSessionMode + created_at: datetime + updated_at: datetime + archived_at: datetime | None = None + is_active: bool + message_count: int + run_metrics: list[SessionRunMetricResponse] = Field(default_factory=list) + + @classmethod + def from_history_record( + cls, + record: ExtractionSessionHistoryRecord, + ) -> "ExtractionSessionHistoryItemResponse": + session = record.session + return cls( + id=session.id, + user_id=session.user_id, + knowledge_graph_id=session.knowledge_graph_id, + mode=session.mode, + created_at=session.created_at, + updated_at=session.updated_at, + archived_at=session.archived_at, + is_active=session.is_active, + message_count=len(session.message_history), + run_metrics=[ + SessionRunMetricResponse.from_domain(metric) + for metric in record.run_metrics + ], + ) + + +class ExtractionSessionHistoryResponse(BaseModel): + """History response for scoped extraction sessions.""" + + sessions: list[ExtractionSessionHistoryItemResponse] + count: int + + class BootstrapIntakePathSelectionRequest(BaseModel): """Request model for bootstrap intake path selection.""" @@ -54,4 +129,3 @@ class BootstrapIntakePathSelectionRequest(BaseModel): default=None, description="Optional user summary of capabilities and schema goals", ) - diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py index 1301cd224..0db6810af 100644 --- a/src/api/extraction/presentation/routes.py +++ b/src/api/extraction/presentation/routes.py @@ -11,6 +11,8 @@ from extraction.domain.value_objects import ExtractionSessionMode from extraction.presentation.models import ( BootstrapIntakePathSelectionRequest, + ExtractionSessionHistoryItemResponse, + ExtractionSessionHistoryResponse, ExtractionSessionListResponse, ExtractionSessionResponse, ) @@ -97,6 +99,36 @@ async def list_sessions( return ExtractionSessionListResponse(sessions=payload, count=len(payload)) +@router.get( + "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/history", + response_model=ExtractionSessionHistoryResponse, +) +async def list_session_history( + knowledge_graph_id: str, + mode: ExtractionSessionMode, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ + ExtractionAgentSessionService, Depends(get_extraction_agent_session_service) + ], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> ExtractionSessionHistoryResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + history = await service.list_session_history( + user_id=current_user.user_id.value, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + payload = [ + ExtractionSessionHistoryItemResponse.from_history_record(record) + for record in history + ] + return ExtractionSessionHistoryResponse(sessions=payload, count=len(payload)) + + @router.post( "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/clear-chat", response_model=ExtractionSessionResponse, diff --git a/src/api/tests/integration/extraction/__init__.py b/src/api/tests/integration/extraction/__init__.py new file mode 100644 index 000000000..c4b79f6e5 --- /dev/null +++ b/src/api/tests/integration/extraction/__init__.py @@ -0,0 +1 @@ +"""Integration tests for Extraction bounded context.""" diff --git a/src/api/tests/integration/extraction/conftest.py b/src/api/tests/integration/extraction/conftest.py new file mode 100644 index 000000000..1ce85faff --- /dev/null +++ b/src/api/tests/integration/extraction/conftest.py @@ -0,0 +1,3 @@ +"""Integration test fixtures for Extraction bounded context.""" + +pytest_plugins = ["tests.integration.management.conftest"] diff --git a/src/api/tests/integration/extraction/test_session_history_retention.py b/src/api/tests/integration/extraction/test_session_history_retention.py new file mode 100644 index 000000000..14e1763bb --- /dev/null +++ b/src/api/tests/integration/extraction/test_session_history_retention.py @@ -0,0 +1,192 @@ +"""Integration tests for archived extraction session history and run metadata.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import text + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.domain.value_objects import ExtractionSessionMode +from extraction.infrastructure.repositories import ( + ExtractionAgentSessionRepository, + ExtractionSessionRunMetricsReader, +) +from management.application.services.data_source_service import DataSourceService +from management.application.services.knowledge_graph_service import KnowledgeGraphService +from management.domain.aggregates import KnowledgeGraph +from management.domain.entities.data_source_sync_run import MutationLogRunMetadata +from management.domain.value_objects import EdgeTypeDefinition, NodeTypeDefinition, OntologyConfig +from shared_kernel.datasource_types import DataSourceAdapterType +from tests.fakes.authorization import InMemoryAuthorizationProvider + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_archived_session_history_retains_linked_run_metadata( + async_session, + clean_management_data: None, + knowledge_graph_repository, + data_source_repository, + data_source_sync_run_repository, + test_tenant: str, + test_workspace: str, +) -> None: + """Clear chat archives sessions while history retrieval keeps run metrics.""" + table_check = await async_session.execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'extraction_agent_sessions' + """ + ) + ) + if table_check.scalar_one_or_none() is None: + pytest.skip("extraction_agent_sessions table is missing in local integration database") + await async_session.rollback() + + user_id = "user-integration-session-history" + authz = InMemoryAuthorizationProvider() + + kg_service = KnowledgeGraphService( + session=async_session, + knowledge_graph_repository=knowledge_graph_repository, + data_source_repository=data_source_repository, + sync_run_repository=data_source_sync_run_repository, + secret_store=None, + authz=authz, + scope_to_tenant=test_tenant, + ) + ds_service = DataSourceService( + session=async_session, + data_source_repository=data_source_repository, + knowledge_graph_repository=knowledge_graph_repository, + sync_run_repository=data_source_sync_run_repository, + secret_store=None, + authz=authz, + scope_to_tenant=test_tenant, + ) + + knowledge_graph = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Session History KG", + description="Archived session history retention", + created_by=user_id, + ) + ontology_config = OntologyConfig( + node_types=( + NodeTypeDefinition(label="Repository"), + NodeTypeDefinition( + label="SeedNode", + prepopulated=True, + prepopulated_instance_count=1, + ), + ), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("SeedNode",), + ), + ), + ) + knowledge_graph.set_ontology(ontology_config) + async with async_session.begin(): + await knowledge_graph_repository.save(knowledge_graph) + + await authz.write_relationship( + f"knowledge_graph:{knowledge_graph.id.value}", + "admin", + f"user:{user_id}", + ) + await kg_service.save_ontology( + user_id=user_id, + kg_id=knowledge_graph.id.value, + config=ontology_config, + ) + transitioned = await kg_service.transition_workspace_to_extraction( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + assert transitioned.session_pointers.active_extraction_operations_session_id is not None + + session_repo = ExtractionAgentSessionRepository(session=async_session) + metrics_reader = ExtractionSessionRunMetricsReader(session=async_session) + session_service = ExtractionAgentSessionService( + repository=session_repo, + run_metrics_reader=metrics_reader, + ) + + active = await session_service.get_or_create_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph.id.value, + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + session_id = active.id + + data_source = await ds_service.create( + user_id=user_id, + kg_id=knowledge_graph.id.value, + name="History Source", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"repo_url": "https://github.com/example/repo"}, + ) + await authz.write_relationship( + f"data_source:{data_source.id.value}", + "manage", + f"user:{user_id}", + ) + sync_run = await ds_service.trigger_sync( + user_id=user_id, + ds_id=data_source.id.value, + ) + sync_run.status = "completed" + sync_run.completed_at = datetime.now(UTC) + sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="mlog-history-001", + knowledge_graph_id=knowledge_graph.id.value, + session_id=session_id, + actor_id=user_id, + started_at=sync_run.started_at, + completed_at=sync_run.completed_at, + token_usage_total=1024, + cost_total_usd=0.88, + operation_counts={"create_node": 4}, + ) + async with async_session.begin(): + await data_source_sync_run_repository.save(sync_run) + + archived_session = await session_service.clear_chat( + user_id=user_id, + knowledge_graph_id=knowledge_graph.id.value, + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + assert archived_session.id != session_id + + history = await session_service.list_session_history( + user_id=user_id, + knowledge_graph_id=knowledge_graph.id.value, + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ) + + assert len(history) == 2 + archived_record = next(item for item in history if item.session.id == session_id) + assert archived_record.session.archived_at is not None + assert archived_record.session.updated_at is not None + assert len(archived_record.run_metrics) == 1 + assert archived_record.run_metrics[0].mutation_log_id == "mlog-history-001" + assert archived_record.run_metrics[0].token_usage_total == 1024 + assert archived_record.run_metrics[0].operation_counts == {"create_node": 4} + + still_archived = await session_repo.get_by_id(session_id) + assert still_archived is not None + assert still_archived.archived_at is not None + + runs = await data_source_sync_run_repository.find_by_data_source(data_source.id.value) + assert len(runs) == 1 + assert runs[0].mutation_log_run is not None + assert runs[0].mutation_log_run.session_id == session_id diff --git a/src/api/tests/integration/management/conftest.py b/src/api/tests/integration/management/conftest.py index 8167f93bf..ebf6f1239 100644 --- a/src/api/tests/integration/management/conftest.py +++ b/src/api/tests/integration/management/conftest.py @@ -105,6 +105,7 @@ async def cleanup() -> None: ) ) await async_session.execute(text("DELETE FROM data_source_sync_runs")) + await async_session.execute(text("DELETE FROM extraction_agent_sessions")) await async_session.execute(text("DELETE FROM data_sources")) await async_session.execute(text("DELETE FROM knowledge_graphs")) await async_session.commit() diff --git a/src/api/tests/unit/extraction/application/test_session_history_service.py b/src/api/tests/unit/extraction/application/test_session_history_service.py new file mode 100644 index 000000000..9977f94c7 --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_session_history_service.py @@ -0,0 +1,164 @@ +"""Unit tests for extraction session history with run-level metrics.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime + +import pytest + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode, ExtractionSessionRunMetric +from extraction.domain.value_objects import ExtractionSessionMode as Mode + + +class _InMemoryAgentSessionRepository: + def __init__(self) -> None: + self._by_id: dict[str, ExtractionAgentSession] = {} + + async def save(self, session: ExtractionAgentSession) -> None: + self._by_id[session.id] = replace(session) + + async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: + session = self._by_id.get(session_id) + return replace(session) if session else None + + async def find_active_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession | None: + for session in self._by_id.values(): + if ( + session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and session.mode == mode + and session.archived_at is None + ): + return replace(session) + return None + + async def list_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + sessions = [ + replace(session) + for session in self._by_id.values() + if session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and (mode is None or session.mode == mode) + ] + return sorted(sessions, key=lambda s: s.updated_at, reverse=True) + + +class _InMemoryRunMetricsReader: + def __init__(self) -> None: + self._metrics: dict[str, list[ExtractionSessionRunMetric]] = {} + + def seed(self, session_id: str, metric: ExtractionSessionRunMetric) -> None: + self._metrics.setdefault(session_id, []).append(metric) + + async def find_metrics_by_session_ids( + self, + *, + knowledge_graph_id: str, + session_ids: list[str], + ) -> dict[str, list[ExtractionSessionRunMetric]]: + del knowledge_graph_id + return { + session_id: list(self._metrics.get(session_id, [])) + for session_id in session_ids + } + + +@pytest.mark.asyncio +class TestExtractionSessionHistoryService: + async def test_list_session_history_includes_archived_sessions_with_metrics(self): + repo = _InMemoryAgentSessionRepository() + metrics_reader = _InMemoryRunMetricsReader() + service = ExtractionAgentSessionService( + repository=repo, + run_metrics_reader=metrics_reader, + ) + + archived = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + archived.message_history = [{"role": "user", "content": "hello"}] + archived.updated_at = datetime(2026, 5, 20, 12, 0, tzinfo=UTC) + await repo.save(archived) + metrics_reader.seed( + archived.id, + ExtractionSessionRunMetric( + sync_run_id="run-1", + mutation_log_id="mlog-1", + status="completed", + started_at=datetime(2026, 5, 20, 11, 0, tzinfo=UTC), + completed_at=datetime(2026, 5, 20, 11, 30, tzinfo=UTC), + token_usage_total=512, + cost_total_usd=0.42, + operation_counts={"create_node": 3}, + ), + ) + + await service.clear_chat( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + + history = await service.list_session_history( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + + assert len(history) == 2 + archived_record = next(item for item in history if item.session.archived_at is not None) + assert archived_record.session.id == archived.id + assert archived_record.session.updated_at is not None + assert archived_record.session.archived_at is not None + assert len(archived_record.run_metrics) == 1 + assert archived_record.run_metrics[0].mutation_log_id == "mlog-1" + assert archived_record.run_metrics[0].token_usage_total == 512 + + async def test_clear_chat_retains_archived_sessions_for_history(self): + repo = _InMemoryAgentSessionRepository() + metrics_reader = _InMemoryRunMetricsReader() + service = ExtractionAgentSessionService( + repository=repo, + run_metrics_reader=metrics_reader, + ) + + first = await service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + await service.clear_chat( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + await service.clear_chat( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + + history = await service.list_session_history( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=Mode.EXTRACTION_OPERATIONS, + ) + + assert len(history) == 3 + assert any(item.session.id == first.id and item.session.archived_at is not None for item in history) + assert sum(1 for item in history if item.session.archived_at is None) == 1 diff --git a/src/api/tests/unit/extraction/presentation/test_routes.py b/src/api/tests/unit/extraction/presentation/test_routes.py index 96a5d1df3..f82a6f5c1 100644 --- a/src/api/tests/unit/extraction/presentation/test_routes.py +++ b/src/api/tests/unit/extraction/presentation/test_routes.py @@ -197,3 +197,30 @@ def test_select_bootstrap_intake_path_persists_choice(self, extraction_client): assert intake["selected_path"] == BootstrapIntakePath.GUIDED_CO_DESIGN.value assert intake["status"] == "path_selected" + def test_session_history_endpoint_returns_archived_sessions_with_run_metrics( + self, extraction_client + ): + client, _ = extraction_client + active = client.get( + "/extraction/knowledge-graphs/kg-123/sessions/extraction_operations/active" + ) + assert active.status_code == status.HTTP_200_OK + archived_id = active.json()["id"] + + client.post( + "/extraction/knowledge-graphs/kg-123/sessions/extraction_operations/clear-chat" + ) + + response = client.get( + "/extraction/knowledge-graphs/kg-123/sessions/extraction_operations/history" + ) + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["count"] == 2 + archived = next( + row for row in payload["sessions"] if row["id"] == archived_id + ) + assert archived["archived_at"] is not None + assert archived["updated_at"] is not None + assert archived["run_metrics"] == [] + diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 21eaf07e4..5ad56ad70 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -78,6 +78,27 @@ interface ExtractionSessionResponse { updated_at: string } +interface SessionRunMetricView { + sync_run_id: string + mutation_log_id: string | null + status: string + started_at: string + completed_at: string | null + token_usage_total: number | null + cost_total_usd: number | null + operation_counts: Record<string, number> +} + +interface ExtractionSessionHistoryItem { + id: string + created_at: string + updated_at: string + archived_at: string | null + is_active: boolean + message_count: number + run_metrics: SessionRunMetricView[] +} + const route = useRoute() const { hasTenant, tenantVersion } = useTenant() const { extractErrorMessage } = useErrorHandler() @@ -90,8 +111,10 @@ const loading = ref(false) const validating = ref(false) const transitioning = ref(false) const sessionLoading = ref(false) +const sessionHistoryLoading = ref(false) const clearingChat = ref(false) const extractionSession = ref<ExtractionSessionResponse | null>(null) +const sessionHistory = ref<ExtractionSessionHistoryItem[]>([]) const extractionTab = ref('extraction-jobs') const draftMessage = ref('') const statusProjection = ref<WorkspaceStatusResponse | null>(null) @@ -311,6 +334,24 @@ async function loadExtractionSession() { } } +async function loadSessionHistory() { + if (!kgId.value) return + sessionHistoryLoading.value = true + try { + const response = await apiFetch<{ sessions: ExtractionSessionHistoryItem[] }>( + `/extraction/knowledge-graphs/${kgId.value}/sessions/${sessionMode.value}/history`, + ) + sessionHistory.value = response.sessions + } catch (err) { + sessionHistory.value = [] + toast.error('Failed to load session history', { + description: extractErrorMessage(err), + }) + } finally { + sessionHistoryLoading.value = false + } +} + async function validateWorkspace() { if (!kgId.value) return validating.value = true @@ -358,6 +399,7 @@ async function clearChat() { { method: 'POST' }, ) toast.success('Extraction chat cleared') + await loadSessionHistory() } catch (err) { toast.error('Failed to clear chat', { description: extractErrorMessage(err), @@ -391,6 +433,7 @@ watch( (mode) => { if (mode) { loadExtractionSession() + loadSessionHistory() } }, ) @@ -749,6 +792,75 @@ watch( </p> </div> </CardContent> + <CardContent class="space-y-3 border-t pt-4"> + <div class="flex items-center justify-between"> + <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> + Session History + </p> + <Button + size="sm" + variant="ghost" + class="h-6 px-2 text-[10px]" + :disabled="sessionHistoryLoading" + @click="loadSessionHistory" + > + Refresh + </Button> + </div> + <div + v-if="sessionHistoryLoading" + class="flex items-center gap-2 text-xs text-muted-foreground" + > + <Loader2 class="size-3.5 animate-spin" /> + Loading session history... + </div> + <div + v-else-if="sessionHistory.length === 0" + class="rounded border border-dashed px-3 py-4 text-xs text-muted-foreground" + > + No archived or active sessions found for this scope yet. + </div> + <div v-else class="space-y-2"> + <div + v-for="entry in sessionHistory" + :key="entry.id" + class="rounded border px-3 py-2 text-xs" + > + <div class="flex flex-wrap items-center justify-between gap-2"> + <p class="font-mono break-all">{{ entry.id }}</p> + <Badge :variant="entry.is_active ? 'default' : 'secondary'"> + {{ entry.is_active ? 'Active' : 'Archived' }} + </Badge> + </div> + <p class="mt-1 text-muted-foreground"> + Updated {{ new Date(entry.updated_at).toLocaleString() }} + <span v-if="entry.archived_at"> + · Archived {{ new Date(entry.archived_at).toLocaleString() }} + </span> + </p> + <p class="mt-1 text-muted-foreground"> + {{ entry.message_count }} message(s) + · {{ entry.run_metrics.length }} linked run(s) + </p> + <div + v-if="entry.run_metrics.length > 0" + class="mt-2 space-y-1.5 rounded border bg-muted/20 p-2" + > + <div + v-for="metric in entry.run_metrics" + :key="metric.sync_run_id" + class="flex flex-wrap items-center justify-between gap-2" + > + <span class="font-mono">{{ metric.mutation_log_id ?? metric.sync_run_id }}</span> + <span class="text-muted-foreground"> + {{ metric.token_usage_total ?? 0 }} tokens · + ${{ (metric.cost_total_usd ?? 0).toFixed(2) }} + </span> + </div> + </div> + </div> + </div> + </CardContent> </Card> <div class="space-y-4"> diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 65555968a..065facfd7 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -72,6 +72,15 @@ describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { expect(manageWorkspaceVue).toContain('active_extraction_operations_session_id') }) + it('loads scoped session history with run metrics after clear chat', () => { + expect(manageWorkspaceVue).toContain('loadSessionHistory') + expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/history') + expect(manageWorkspaceVue).toContain('sessionHistory') + expect(manageWorkspaceVue).toContain('run_metrics') + expect(manageWorkspaceVue).toContain('Session History') + }) + + it('uses shared conversation panel for bootstrap and extraction sessions', () => { expect(manageWorkspaceVue).toContain('SharedConversationPanel') expect(manageWorkspaceVue).toContain('sessionMode') @@ -81,7 +90,7 @@ describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { it('supports explicit Clear chat reset for extraction session', () => { expect(manageWorkspaceVue).toContain('clearChat') expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/clear-chat') - expect(manageWorkspaceVue).toContain('Clear chat') + expect(sharedConversationPanelVue).toContain('Clear chat') }) it('provides tabbed lower operations area for extraction workflows', () => { From cef63a1dabb7640f599408d2fce4932c31aa347a Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:21:09 -0400 Subject: [PATCH 44/80] feat(management): persist canonical schema via graph type definitions (#733) Route ontology save/read and workspace readiness through KG-scoped mutation-log DEFINE persistence, with JSONB fallback for unmigrated data. Partial for #718: graph schema API and extraction bootstrap still use legacy in-memory type-definition storage pending full migration. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../graph/infrastructure/models/__init__.py | 1 + .../models/knowledge_graph_type_definition.py | 32 +++ .../infrastructure/noop_mutation_applier.py | 14 ++ .../postgres_kg_type_definition_store.py | 117 ++++++++++ .../canonical_schema/__init__.py | 1 + .../graph_canonical_schema_repository.py | 122 ++++++++++ .../ontology_mutation_builder.py | 56 +++++ .../canonical_schema/ontology_projection.py | 50 +++++ ...3e4f5a_create_kg_type_definitions_table.py | 63 ++++++ .../services/knowledge_graph_service.py | 40 +++- .../dependencies/knowledge_graph.py | 4 + src/api/management/ports/canonical_schema.py | 24 ++ src/api/management/ports/exceptions.py | 6 + src/api/tests/fakes/canonical_schema.py | 28 +++ .../tests/integration/management/conftest.py | 3 + .../test_canonical_schema_source.py | 209 ++++++++++++++++++ .../test_ontology_mutation_builder.py | 42 ++++ .../test_canonical_schema_service.py | 134 +++++++++++ .../test_knowledge_graph_service.py | 97 ++++---- 19 files changed, 983 insertions(+), 60 deletions(-) create mode 100644 src/api/graph/infrastructure/models/__init__.py create mode 100644 src/api/graph/infrastructure/models/knowledge_graph_type_definition.py create mode 100644 src/api/graph/infrastructure/noop_mutation_applier.py create mode 100644 src/api/graph/infrastructure/postgres_kg_type_definition_store.py create mode 100644 src/api/infrastructure/canonical_schema/__init__.py create mode 100644 src/api/infrastructure/canonical_schema/graph_canonical_schema_repository.py create mode 100644 src/api/infrastructure/canonical_schema/ontology_mutation_builder.py create mode 100644 src/api/infrastructure/canonical_schema/ontology_projection.py create mode 100644 src/api/infrastructure/migrations/versions/fb1c2d3e4f5a_create_kg_type_definitions_table.py create mode 100644 src/api/management/ports/canonical_schema.py create mode 100644 src/api/tests/fakes/canonical_schema.py create mode 100644 src/api/tests/integration/management/test_canonical_schema_source.py create mode 100644 src/api/tests/unit/infrastructure/canonical_schema/test_ontology_mutation_builder.py create mode 100644 src/api/tests/unit/management/application/test_canonical_schema_service.py diff --git a/src/api/graph/infrastructure/models/__init__.py b/src/api/graph/infrastructure/models/__init__.py new file mode 100644 index 000000000..978cc41f8 --- /dev/null +++ b/src/api/graph/infrastructure/models/__init__.py @@ -0,0 +1 @@ +"""Graph infrastructure SQLAlchemy models.""" diff --git a/src/api/graph/infrastructure/models/knowledge_graph_type_definition.py b/src/api/graph/infrastructure/models/knowledge_graph_type_definition.py new file mode 100644 index 000000000..102cb1546 --- /dev/null +++ b/src/api/graph/infrastructure/models/knowledge_graph_type_definition.py @@ -0,0 +1,32 @@ +"""SQLAlchemy model for KG-scoped graph type definitions.""" + +from __future__ import annotations + +from sqlalchemy import String, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from infrastructure.database.models import Base + + +class KnowledgeGraphTypeDefinitionModel(Base): + """Persisted type definition for a knowledge graph schema layer.""" + + __tablename__ = "knowledge_graph_type_definitions" + __table_args__ = ( + UniqueConstraint( + "knowledge_graph_id", + "entity_type", + "label", + name="uq_kg_type_definitions_kg_entity_label", + ), + ) + + id: Mapped[str] = mapped_column(String(26), primary_key=True) + knowledge_graph_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + entity_type: Mapped[str] = mapped_column(String(16), nullable=False) + label: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(String(2048), nullable=False, default="") + required_properties: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + optional_properties: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + metadata_json: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) diff --git a/src/api/graph/infrastructure/noop_mutation_applier.py b/src/api/graph/infrastructure/noop_mutation_applier.py new file mode 100644 index 000000000..6832c440f --- /dev/null +++ b/src/api/graph/infrastructure/noop_mutation_applier.py @@ -0,0 +1,14 @@ +"""No-op mutation applier for schema-only DEFINE batches.""" + +from __future__ import annotations + +from graph.domain.value_objects import MutationOperation, MutationResult + + +class NoOpMutationApplier: + """Accept mutation batches without touching the graph database.""" + + def apply_batch(self, operations: list[MutationOperation]) -> MutationResult: + """Report success for schema-only batches.""" + _ = operations + return MutationResult(success=True, operations_applied=0) diff --git a/src/api/graph/infrastructure/postgres_kg_type_definition_store.py b/src/api/graph/infrastructure/postgres_kg_type_definition_store.py new file mode 100644 index 000000000..dedd29209 --- /dev/null +++ b/src/api/graph/infrastructure/postgres_kg_type_definition_store.py @@ -0,0 +1,117 @@ +"""Postgres-backed canonical schema storage for knowledge graphs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from ulid import ULID + +from graph.domain.value_objects import EntityType, TypeDefinition +from graph.infrastructure.models.knowledge_graph_type_definition import ( + KnowledgeGraphTypeDefinitionModel, +) + + +@dataclass(frozen=True) +class StoredKnowledgeGraphTypeDefinition: + """Canonical type definition row projected for cross-context mapping.""" + + label: str + entity_type: str + description: str + required_properties: tuple[str, ...] + optional_properties: tuple[str, ...] + metadata: dict[str, Any] + + +class PostgresKnowledgeGraphTypeDefinitionStore: + """Async persistence for KG-scoped canonical type definitions.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def delete_all_for_kg(self, kg_id: str) -> None: + """Remove all type definitions for a knowledge graph.""" + stmt = delete(KnowledgeGraphTypeDefinitionModel).where( + KnowledgeGraphTypeDefinitionModel.knowledge_graph_id == kg_id + ) + await self._session.execute(stmt) + + async def upsert_type_definition( + self, + *, + kg_id: str, + type_def: TypeDefinition, + metadata: dict[str, Any] | None = None, + ) -> None: + """Insert or replace a single type definition row.""" + entity_type = type_def.entity_type.value + stmt = select(KnowledgeGraphTypeDefinitionModel).where( + KnowledgeGraphTypeDefinitionModel.knowledge_graph_id == kg_id, + KnowledgeGraphTypeDefinitionModel.entity_type == entity_type, + KnowledgeGraphTypeDefinitionModel.label == type_def.label, + ) + result = await self._session.execute(stmt) + existing = result.scalar_one_or_none() + + payload = { + "description": type_def.description, + "required_properties": sorted(type_def.required_properties), + "optional_properties": sorted(type_def.optional_properties), + "metadata_json": metadata, + } + + if existing is None: + model = KnowledgeGraphTypeDefinitionModel( + id=str(ULID()), + knowledge_graph_id=kg_id, + entity_type=entity_type, + label=type_def.label, + **payload, + ) + self._session.add(model) + else: + existing.description = payload["description"] + existing.required_properties = payload["required_properties"] + existing.optional_properties = payload["optional_properties"] + existing.metadata_json = payload["metadata_json"] + + await self._session.flush() + + async def list_for_kg(self, kg_id: str) -> list[StoredKnowledgeGraphTypeDefinition]: + """Return all canonical type definitions for a knowledge graph.""" + stmt = ( + select(KnowledgeGraphTypeDefinitionModel) + .where(KnowledgeGraphTypeDefinitionModel.knowledge_graph_id == kg_id) + .order_by( + KnowledgeGraphTypeDefinitionModel.entity_type, + KnowledgeGraphTypeDefinitionModel.label, + ) + ) + result = await self._session.execute(stmt) + return [self._to_stored(row) for row in result.scalars().all()] + + @staticmethod + def to_type_definition(stored: StoredKnowledgeGraphTypeDefinition) -> TypeDefinition: + """Convert a stored projection to a graph TypeDefinition.""" + return TypeDefinition( + label=stored.label, + entity_type=EntityType(stored.entity_type), + description=stored.description, + required_properties=set(stored.required_properties), + optional_properties=set(stored.optional_properties), + ) + + @staticmethod + def _to_stored(model: KnowledgeGraphTypeDefinitionModel) -> StoredKnowledgeGraphTypeDefinition: + return StoredKnowledgeGraphTypeDefinition( + label=model.label, + entity_type=model.entity_type, + description=model.description, + required_properties=tuple(model.required_properties or []), + optional_properties=tuple(model.optional_properties or []), + metadata=model.metadata_json or {}, + ) diff --git a/src/api/infrastructure/canonical_schema/__init__.py b/src/api/infrastructure/canonical_schema/__init__.py new file mode 100644 index 000000000..42ea042a4 --- /dev/null +++ b/src/api/infrastructure/canonical_schema/__init__.py @@ -0,0 +1 @@ +"""Cross-context canonical schema wiring.""" diff --git a/src/api/infrastructure/canonical_schema/graph_canonical_schema_repository.py b/src/api/infrastructure/canonical_schema/graph_canonical_schema_repository.py new file mode 100644 index 000000000..6a023b13c --- /dev/null +++ b/src/api/infrastructure/canonical_schema/graph_canonical_schema_repository.py @@ -0,0 +1,122 @@ +"""Graph-backed implementation of Management canonical schema port.""" + +from __future__ import annotations + +import json +from typing import Any + +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from graph.application.services.graph_mutation_service import GraphMutationService +from graph.domain.value_objects import EntityType, MutationOperation +from graph.infrastructure.noop_mutation_applier import NoOpMutationApplier +from graph.infrastructure.postgres_kg_type_definition_store import ( + PostgresKnowledgeGraphTypeDefinitionStore, +) +from graph.infrastructure.type_definition_repository import InMemoryTypeDefinitionRepository +from infrastructure.canonical_schema.ontology_mutation_builder import ( + edge_type_metadata, + node_type_metadata, + ontology_config_to_define_operations, +) +from infrastructure.canonical_schema.ontology_projection import ( + stored_definitions_to_ontology_config, +) +from management.domain.value_objects import OntologyConfig +from management.ports.canonical_schema import ICanonicalSchemaRepository +from management.ports.exceptions import CanonicalSchemaMutationError + + +class _CollectingTypeDefinitionRepository(InMemoryTypeDefinitionRepository): + """In-memory repository used while applying canonical schema mutations.""" + + pass + + +class GraphCanonicalSchemaRepository(ICanonicalSchemaRepository): + """Persist and read canonical schema through mutation-log DEFINE operations.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._store = PostgresKnowledgeGraphTypeDefinitionStore(session) + + async def get_ontology(self, kg_id: str) -> OntologyConfig | None: + rows = await self._store.list_for_kg(kg_id) + if not rows: + return None + return stored_definitions_to_ontology_config(rows) + + async def replace_ontology(self, kg_id: str, config: OntologyConfig) -> None: + await self._store.delete_all_for_kg(kg_id) + await self._apply_operations( + kg_id, ontology_config_to_define_operations(config), config + ) + + async def apply_mutation_log(self, kg_id: str, jsonl_content: str) -> None: + operations = self._parse_jsonl(jsonl_content) + if not operations: + return + + existing = await self.get_ontology(kg_id) or OntologyConfig() + await self._apply_operations(kg_id, operations, existing) + + async def _apply_operations( + self, + kg_id: str, + operations: list[MutationOperation], + config: OntologyConfig, + ) -> None: + metadata_by_key = _metadata_map_for_config(config) + repo = _CollectingTypeDefinitionRepository() + + for row in await self._store.list_for_kg(kg_id): + repo.save(self._store.to_type_definition(row)) + + service = GraphMutationService( + mutation_applier=NoOpMutationApplier(), + type_definition_repository=repo, + ) + result = service.apply_mutations(operations, knowledge_graph_id=kg_id) + if not result.success: + message = "; ".join(result.errors) if result.errors else "mutation failed" + raise CanonicalSchemaMutationError(message) + + for type_def in repo.get_all(): + metadata = metadata_by_key.get((type_def.label, type_def.entity_type.value)) + await self._store.upsert_type_definition( + kg_id=kg_id, + type_def=type_def, + metadata=metadata, + ) + + @staticmethod + def _parse_jsonl(jsonl_content: str) -> list[MutationOperation]: + operations: list[MutationOperation] = [] + for line_num, line in enumerate(jsonl_content.strip().split("\n"), start=1): + stripped = line.strip() + if not stripped: + continue + try: + operations.append(MutationOperation(**json.loads(stripped))) + except json.JSONDecodeError as exc: + raise CanonicalSchemaMutationError( + f"JSON parse error on line {line_num}: {exc}" + ) from exc + except ValidationError as exc: + raise CanonicalSchemaMutationError( + f"Validation error on line {line_num}: {exc}" + ) from exc + return operations + + +def _metadata_map_for_config( + config: OntologyConfig, +) -> dict[tuple[str, str], dict[str, Any]]: + """Build lookup for authoring metadata preserved outside graph TypeDefinition.""" + metadata: dict[tuple[str, str], dict[str, Any]] = {} + for node_type in config.node_types: + metadata[(node_type.label, EntityType.NODE.value)] = node_type_metadata(node_type) + for edge_type in config.edge_types: + metadata[(edge_type.label, EntityType.EDGE.value)] = edge_type_metadata(edge_type) + return metadata diff --git a/src/api/infrastructure/canonical_schema/ontology_mutation_builder.py b/src/api/infrastructure/canonical_schema/ontology_mutation_builder.py new file mode 100644 index 000000000..fdfec233b --- /dev/null +++ b/src/api/infrastructure/canonical_schema/ontology_mutation_builder.py @@ -0,0 +1,56 @@ +"""Bridge Management ontology configs to graph DEFINE mutation operations.""" + +from __future__ import annotations + +from graph.domain.value_objects import EntityType, MutationOperation, MutationOperationType +from management.domain.value_objects import OntologyConfig + + +def ontology_config_to_define_operations( + config: OntologyConfig, +) -> list[MutationOperation]: + """Convert an ontology config into DEFINE mutation operations.""" + operations: list[MutationOperation] = [] + + for node_type in config.node_types: + operations.append( + MutationOperation( + op=MutationOperationType.DEFINE, + type=EntityType.NODE, + label=node_type.label, + description=node_type.description or node_type.label, + required_properties=set(node_type.required_properties), + optional_properties=set(node_type.optional_properties), + ) + ) + + for edge_type in config.edge_types: + operations.append( + MutationOperation( + op=MutationOperationType.DEFINE, + type=EntityType.EDGE, + label=edge_type.label, + description=edge_type.description or edge_type.label, + required_properties=set(edge_type.properties), + optional_properties=set(), + ) + ) + + return operations + + +def node_type_metadata(node_type) -> dict: + """Serialize node-type authoring metadata for canonical storage.""" + return { + "prepopulated": node_type.prepopulated, + "prepopulated_instance_count": node_type.prepopulated_instance_count, + } + + +def edge_type_metadata(edge_type) -> dict: + """Serialize edge-type authoring metadata for canonical storage.""" + return { + "source_labels": list(edge_type.source_labels), + "target_labels": list(edge_type.target_labels), + "properties": list(edge_type.properties), + } diff --git a/src/api/infrastructure/canonical_schema/ontology_projection.py b/src/api/infrastructure/canonical_schema/ontology_projection.py new file mode 100644 index 000000000..e8e89101f --- /dev/null +++ b/src/api/infrastructure/canonical_schema/ontology_projection.py @@ -0,0 +1,50 @@ +"""Map stored canonical schema rows to Management ontology configs.""" + +from __future__ import annotations + +from graph.infrastructure.postgres_kg_type_definition_store import ( + StoredKnowledgeGraphTypeDefinition, +) +from management.domain.value_objects import ( + EdgeTypeDefinition, + NodeTypeDefinition, + OntologyConfig, +) + + +def stored_definitions_to_ontology_config( + stored_definitions: list[StoredKnowledgeGraphTypeDefinition], +) -> OntologyConfig: + """Project graph-native type definitions to Management OntologyConfig.""" + node_types: list[NodeTypeDefinition] = [] + edge_types: list[EdgeTypeDefinition] = [] + + for stored in stored_definitions: + if stored.entity_type == "node": + node_types.append( + NodeTypeDefinition( + label=stored.label, + description=stored.description, + required_properties=stored.required_properties, + optional_properties=stored.optional_properties, + prepopulated=bool(stored.metadata.get("prepopulated", False)), + prepopulated_instance_count=int( + stored.metadata.get("prepopulated_instance_count", 0) + ), + ) + ) + elif stored.entity_type == "edge": + edge_types.append( + EdgeTypeDefinition( + label=stored.label, + description=stored.description, + source_labels=tuple(stored.metadata.get("source_labels", [])), + target_labels=tuple(stored.metadata.get("target_labels", [])), + properties=tuple(stored.metadata.get("properties", [])), + ) + ) + + return OntologyConfig( + node_types=tuple(node_types), + edge_types=tuple(edge_types), + ) diff --git a/src/api/infrastructure/migrations/versions/fb1c2d3e4f5a_create_kg_type_definitions_table.py b/src/api/infrastructure/migrations/versions/fb1c2d3e4f5a_create_kg_type_definitions_table.py new file mode 100644 index 000000000..2d0b2a2f5 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/fb1c2d3e4f5a_create_kg_type_definitions_table.py @@ -0,0 +1,63 @@ +"""Create knowledge_graph_type_definitions table for canonical schema storage. + +Revision ID: fb1c2d3e4f5a +Revises: fa0b1c2d3e4f +Create Date: 2026-05-22 10:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "fb1c2d3e4f5a" +down_revision = "fa0b1c2d3e4f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create table for graph-native canonical type definitions.""" + op.create_table( + "knowledge_graph_type_definitions", + sa.Column("id", sa.String(length=26), nullable=False), + sa.Column("knowledge_graph_id", sa.String(length=26), nullable=False), + sa.Column("entity_type", sa.String(length=16), nullable=False), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(length=2048), nullable=False, server_default=""), + sa.Column( + "required_properties", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column( + "optional_properties", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "knowledge_graph_id", + "entity_type", + "label", + name="uq_kg_type_definitions_kg_entity_label", + ), + ) + op.create_index( + "ix_knowledge_graph_type_definitions_knowledge_graph_id", + "knowledge_graph_type_definitions", + ["knowledge_graph_id"], + ) + + +def downgrade() -> None: + """Drop canonical type definition table.""" + op.drop_index( + "ix_knowledge_graph_type_definitions_knowledge_graph_id", + table_name="knowledge_graph_type_definitions", + ) + op.drop_table("knowledge_graph_type_definitions") diff --git a/src/api/management/application/services/knowledge_graph_service.py b/src/api/management/application/services/knowledge_graph_service.py index 303b43bf4..ea9101d26 100644 --- a/src/api/management/application/services/knowledge_graph_service.py +++ b/src/api/management/application/services/knowledge_graph_service.py @@ -41,6 +41,7 @@ IDataSourceSyncRunRepository, IKnowledgeGraphRepository, ) +from management.ports.canonical_schema import ICanonicalSchemaRepository from management.ports.secret_store import ISecretStoreRepository from shared_kernel.authorization.protocols import AuthorizationProvider from shared_kernel.authorization.types import ( @@ -69,6 +70,7 @@ def __init__( data_source_repository: IDataSourceRepository | None = None, sync_run_repository: IDataSourceSyncRunRepository | None = None, secret_store: ISecretStoreRepository | None = None, + canonical_schema_repository: ICanonicalSchemaRepository | None = None, ) -> None: """Initialize KnowledgeGraphService with dependencies. @@ -80,6 +82,7 @@ def __init__( probe: Optional domain probe for observability data_source_repository: Optional DS repository for cascade delete secret_store: Optional secret store for credential cleanup on cascade delete + canonical_schema_repository: Optional graph-native canonical schema store """ self._session = session self._kg_repo = knowledge_graph_repository @@ -89,6 +92,7 @@ def __init__( self._ds_repo = data_source_repository self._sync_run_repo = sync_run_repository self._secret_store = secret_store + self._canonical_schema_repo = canonical_schema_repository def _compute_next_run_at_utc( self, @@ -764,7 +768,7 @@ async def get_ontology( if not has_view: return None - return await self._kg_repo.get_ontology(kg_id) + return await self._resolve_canonical_ontology(kg_id) async def save_ontology( self, @@ -809,22 +813,33 @@ async def save_ontology( if kg is None or kg.tenant_id != self._scope_to_tenant: raise KnowledgeGraphNotFoundError(f"Knowledge graph {kg_id} not found") - await self._kg_repo.save_ontology(kg_id, config) + if self._canonical_schema_repo is not None: + await self._canonical_schema_repo.replace_ontology(kg_id, config) + else: + await self._kg_repo.save_ontology(kg_id, config) await self._session.commit() return config + async def _resolve_canonical_ontology(self, kg_id: str) -> OntologyConfig | None: + """Load canonical schema from graph-native storage with JSONB fallback.""" + if self._canonical_schema_repo is not None: + canonical = await self._canonical_schema_repo.get_ontology(kg_id) + if canonical is not None: + return canonical + return await self._kg_repo.get_ontology(kg_id) + def _evaluate_workspace_readiness( - self, kg: KnowledgeGraph + self, ontology: OntologyConfig | None ) -> WorkspaceReadinessStatus: - """Evaluate transition readiness flags for workspace status projection.""" - node_type_count = len(kg.ontology.node_types) if kg.ontology else 0 - edge_type_count = len(kg.ontology.edge_types) if kg.ontology else 0 + """Evaluate transition readiness flags from canonical schema state.""" + node_type_count = len(ontology.node_types) if ontology else 0 + edge_type_count = len(ontology.edge_types) if ontology else 0 prepopulated_without_instances: tuple[str, ...] = () - if kg.ontology is not None: + if ontology is not None: prepopulated_without_instances = tuple( node_type.label - for node_type in kg.ontology.node_types + for node_type in ontology.node_types if node_type.prepopulated and node_type.prepopulated_instance_count <= 0 ) @@ -870,7 +885,8 @@ async def get_workspace_status( if not has_view: return None - readiness = self._evaluate_workspace_readiness(kg) + ontology = await self._resolve_canonical_ontology(kg_id) + readiness = self._evaluate_workspace_readiness(ontology) transition_eligible = ( kg.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP and readiness.is_ready ) @@ -915,7 +931,8 @@ async def validate_workspace( if kg is None or kg.tenant_id != self._scope_to_tenant: raise KnowledgeGraphNotFoundError(f"Knowledge graph {kg_id} not found") - readiness = self._evaluate_workspace_readiness(kg) + ontology = await self._resolve_canonical_ontology(kg_id) + readiness = self._evaluate_workspace_readiness(ontology) transition_eligible = ( kg.workspace_mode == WorkspaceMode.SCHEMA_BOOTSTRAP and readiness.is_ready ) @@ -945,7 +962,8 @@ async def transition_workspace_to_extraction( if kg is None or kg.tenant_id != self._scope_to_tenant: raise KnowledgeGraphNotFoundError(f"Knowledge graph {kg_id} not found") - readiness = self._evaluate_workspace_readiness(kg) + ontology = await self._resolve_canonical_ontology(kg_id) + readiness = self._evaluate_workspace_readiness(ontology) if not readiness.is_ready: joined_reasons = "; ".join(readiness.blocking_reasons) raise ValueError( diff --git a/src/api/management/dependencies/knowledge_graph.py b/src/api/management/dependencies/knowledge_graph.py index bd298cd9d..03ab069f4 100644 --- a/src/api/management/dependencies/knowledge_graph.py +++ b/src/api/management/dependencies/knowledge_graph.py @@ -25,6 +25,9 @@ FernetSecretStore, KnowledgeGraphRepository, ) +from infrastructure.canonical_schema.graph_canonical_schema_repository import ( + GraphCanonicalSchemaRepository, +) from shared_kernel.authorization.protocols import AuthorizationProvider @@ -62,4 +65,5 @@ def get_knowledge_graph_service( authz=authz, scope_to_tenant=current_user.tenant_id.value, probe=DefaultKnowledgeGraphServiceProbe(), + canonical_schema_repository=GraphCanonicalSchemaRepository(session), ) diff --git a/src/api/management/ports/canonical_schema.py b/src/api/management/ports/canonical_schema.py new file mode 100644 index 000000000..46e2a513d --- /dev/null +++ b/src/api/management/ports/canonical_schema.py @@ -0,0 +1,24 @@ +"""Port for graph-native canonical schema access in Management.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from management.domain.value_objects import OntologyConfig + + +@runtime_checkable +class ICanonicalSchemaRepository(Protocol): + """Read/write canonical schema state stored as graph type definitions.""" + + async def get_ontology(self, kg_id: str) -> OntologyConfig | None: + """Return canonical schema for a knowledge graph, if any exists.""" + ... + + async def replace_ontology(self, kg_id: str, config: OntologyConfig) -> None: + """Replace canonical schema via mutation-log DEFINE operations.""" + ... + + async def apply_mutation_log(self, kg_id: str, jsonl_content: str) -> None: + """Apply additive schema/entity mutations from JSONL content.""" + ... diff --git a/src/api/management/ports/exceptions.py b/src/api/management/ports/exceptions.py index a001125e4..9f9840c75 100644 --- a/src/api/management/ports/exceptions.py +++ b/src/api/management/ports/exceptions.py @@ -48,3 +48,9 @@ class UnauthorizedError(Exception): """ pass + + +class CanonicalSchemaMutationError(Exception): + """Raised when canonical schema mutation-log application fails.""" + + pass diff --git a/src/api/tests/fakes/canonical_schema.py b/src/api/tests/fakes/canonical_schema.py new file mode 100644 index 000000000..d99be1c19 --- /dev/null +++ b/src/api/tests/fakes/canonical_schema.py @@ -0,0 +1,28 @@ +"""In-memory fake for ICanonicalSchemaRepository.""" + +from __future__ import annotations + +from management.domain.value_objects import OntologyConfig + + +class InMemoryCanonicalSchemaRepository: + """Stores canonical schema per knowledge graph for unit tests.""" + + def __init__(self) -> None: + self._store: dict[str, OntologyConfig] = {} + self.replaced: list[tuple[str, OntologyConfig]] = [] + self.applied_logs: list[tuple[str, str]] = [] + + async def get_ontology(self, kg_id: str) -> OntologyConfig | None: + return self._store.get(kg_id) + + async def replace_ontology(self, kg_id: str, config: OntologyConfig) -> None: + self.replaced.append((kg_id, config)) + self._store[kg_id] = config + + async def apply_mutation_log(self, kg_id: str, jsonl_content: str) -> None: + self.applied_logs.append((kg_id, jsonl_content)) + + def seed(self, kg_id: str, config: OntologyConfig) -> None: + """Preload canonical schema for a knowledge graph.""" + self._store[kg_id] = config diff --git a/src/api/tests/integration/management/conftest.py b/src/api/tests/integration/management/conftest.py index ebf6f1239..3d40e75f3 100644 --- a/src/api/tests/integration/management/conftest.py +++ b/src/api/tests/integration/management/conftest.py @@ -107,6 +107,9 @@ async def cleanup() -> None: await async_session.execute(text("DELETE FROM data_source_sync_runs")) await async_session.execute(text("DELETE FROM extraction_agent_sessions")) await async_session.execute(text("DELETE FROM data_sources")) + await async_session.execute( + text("DELETE FROM knowledge_graph_type_definitions") + ) await async_session.execute(text("DELETE FROM knowledge_graphs")) await async_session.commit() except ProgrammingError: diff --git a/src/api/tests/integration/management/test_canonical_schema_source.py b/src/api/tests/integration/management/test_canonical_schema_source.py new file mode 100644 index 000000000..ff7c706ad --- /dev/null +++ b/src/api/tests/integration/management/test_canonical_schema_source.py @@ -0,0 +1,209 @@ +"""Integration tests for canonical graph-native schema storage.""" + +from __future__ import annotations + +import json + +import pytest +from sqlalchemy import text + +from graph.domain.value_objects import EntityType, MutationOperationType +from infrastructure.canonical_schema.graph_canonical_schema_repository import ( + GraphCanonicalSchemaRepository, +) +from management.application.services.knowledge_graph_service import KnowledgeGraphService +from management.domain.aggregates import KnowledgeGraph +from management.domain.value_objects import EdgeTypeDefinition, NodeTypeDefinition, OntologyConfig +from tests.fakes.authorization import InMemoryAuthorizationProvider + +pytestmark = pytest.mark.integration + + +async def _table_exists(async_session, table_name: str) -> bool: + result = await async_session.execute( + text( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_name = :table_name + """ + ), + {"table_name": table_name}, + ) + return result.scalar_one_or_none() is not None + + +@pytest.mark.asyncio +async def test_bootstrap_schema_persisted_in_canonical_store_and_readiness( + async_session, + clean_management_data: None, + knowledge_graph_repository, + test_tenant: str, + test_workspace: str, +) -> None: + """Bootstrap ontology flows through mutation-log DEFINE path into canonical store.""" + if not await _table_exists(async_session, "knowledge_graph_type_definitions"): + pytest.skip("knowledge_graph_type_definitions table is missing") + + await async_session.rollback() + + user_id = "user-canonical-schema-001" + authz = InMemoryAuthorizationProvider() + canonical_repo = GraphCanonicalSchemaRepository(async_session) + kg_service = KnowledgeGraphService( + session=async_session, + knowledge_graph_repository=knowledge_graph_repository, + authz=authz, + scope_to_tenant=test_tenant, + canonical_schema_repository=canonical_repo, + ) + + knowledge_graph = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Canonical Schema KG", + description="Bootstrap canonical schema", + created_by=user_id, + ) + ontology_config = OntologyConfig( + node_types=( + NodeTypeDefinition(label="Repository"), + NodeTypeDefinition( + label="SeedNode", + prepopulated=True, + prepopulated_instance_count=1, + ), + ), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("SeedNode",), + ), + ), + ) + + async with async_session.begin(): + await knowledge_graph_repository.save(knowledge_graph) + + await authz.write_relationship( + f"knowledge_graph:{knowledge_graph.id.value}", + "admin", + f"user:{user_id}", + ) + + await kg_service.save_ontology( + user_id=user_id, + kg_id=knowledge_graph.id.value, + config=ontology_config, + ) + + canonical = await canonical_repo.get_ontology(knowledge_graph.id.value) + assert canonical is not None + assert {node.label for node in canonical.node_types} == {"Repository", "SeedNode"} + + row_count = await async_session.execute( + text( + """ + SELECT COUNT(*) AS count + FROM knowledge_graph_type_definitions + WHERE knowledge_graph_id = :kg_id + """ + ), + {"kg_id": knowledge_graph.id.value}, + ) + assert row_count.scalar_one() == 3 + + status = await kg_service.get_workspace_status( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + assert status is not None + assert status.transition_eligible is True + + +@pytest.mark.asyncio +async def test_additive_schema_evolution_in_extraction_mode( + async_session, + clean_management_data: None, + knowledge_graph_repository, + test_tenant: str, + test_workspace: str, +) -> None: + """Extraction mode accepts additive DEFINE mutations via mutation log.""" + if not await _table_exists(async_session, "knowledge_graph_type_definitions"): + pytest.skip("knowledge_graph_type_definitions table is missing") + + await async_session.rollback() + + user_id = "user-canonical-schema-002" + authz = InMemoryAuthorizationProvider() + canonical_repo = GraphCanonicalSchemaRepository(async_session) + kg_service = KnowledgeGraphService( + session=async_session, + knowledge_graph_repository=knowledge_graph_repository, + authz=authz, + scope_to_tenant=test_tenant, + canonical_schema_repository=canonical_repo, + ) + + knowledge_graph = KnowledgeGraph.create( + tenant_id=test_tenant, + workspace_id=test_workspace, + name="Schema Evolution KG", + description="Additive schema evolution", + created_by=user_id, + ) + bootstrap_config = OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ) + + async with async_session.begin(): + await knowledge_graph_repository.save(knowledge_graph) + + await authz.write_relationship( + f"knowledge_graph:{knowledge_graph.id.value}", + "admin", + f"user:{user_id}", + ) + await kg_service.save_ontology( + user_id=user_id, + kg_id=knowledge_graph.id.value, + config=bootstrap_config, + ) + await kg_service.transition_workspace_to_extraction( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + + additive_define = { + "op": MutationOperationType.DEFINE.value, + "type": EntityType.NODE.value, + "label": "Service", + "description": "A deployable service", + "required_properties": ["slug", "name"], + "optional_properties": [], + } + await canonical_repo.apply_mutation_log( + knowledge_graph.id.value, + json.dumps(additive_define), + ) + await async_session.commit() + + canonical = await canonical_repo.get_ontology(knowledge_graph.id.value) + assert canonical is not None + assert {node.label for node in canonical.node_types} == {"Repository", "Service"} + + status = await kg_service.get_workspace_status( + user_id=user_id, + kg_id=knowledge_graph.id.value, + ) + assert status is not None + assert status.workspace_mode.value == "extraction_operations" diff --git a/src/api/tests/unit/infrastructure/canonical_schema/test_ontology_mutation_builder.py b/src/api/tests/unit/infrastructure/canonical_schema/test_ontology_mutation_builder.py new file mode 100644 index 000000000..24602750c --- /dev/null +++ b/src/api/tests/unit/infrastructure/canonical_schema/test_ontology_mutation_builder.py @@ -0,0 +1,42 @@ +"""Unit tests for ontology to DEFINE mutation conversion.""" + +from __future__ import annotations + +from infrastructure.canonical_schema.ontology_mutation_builder import ( + ontology_config_to_define_operations, +) +from management.domain.value_objects import ( + EdgeTypeDefinition, + NodeTypeDefinition, + OntologyConfig, +) + + +class TestOntologyConfigToDefineOperations: + def test_converts_node_and_edge_types(self): + config = OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository", description="Repo"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + description="Contains relationship", + source_labels=("Repository",), + target_labels=("Repository",), + properties=("weight",), + ), + ), + ) + + operations = ontology_config_to_define_operations(config) + + assert len(operations) == 2 + node_op = operations[0] + edge_op = operations[1] + assert node_op.op == "DEFINE" + assert node_op.type == "node" + assert node_op.label == "Repository" + assert node_op.description == "Repo" + assert edge_op.op == "DEFINE" + assert edge_op.type == "edge" + assert edge_op.label == "CONTAINS" + assert edge_op.required_properties == {"weight"} diff --git a/src/api/tests/unit/management/application/test_canonical_schema_service.py b/src/api/tests/unit/management/application/test_canonical_schema_service.py new file mode 100644 index 000000000..07f41aae7 --- /dev/null +++ b/src/api/tests/unit/management/application/test_canonical_schema_service.py @@ -0,0 +1,134 @@ +"""Unit tests for canonical schema integration in KnowledgeGraphService.""" + +from __future__ import annotations + +import pytest + +from management.application.services.knowledge_graph_service import KnowledgeGraphService +from management.domain.value_objects import ( + EdgeTypeDefinition, + NodeTypeDefinition, + OntologyConfig, +) +from tests.fakes.authorization import InMemoryAuthorizationProvider +from tests.fakes.canonical_schema import InMemoryCanonicalSchemaRepository +from tests.fakes.management import ( + InMemoryDataSourceRepository, + InMemoryKnowledgeGraphRepository, + InMemorySecretStoreRepository, + RecordingKnowledgeGraphServiceProbe, +) +from tests.unit.management.application.test_knowledge_graph_service import ( + _grant_kg_edit, + _grant_kg_view, + _make_kg, +) + + +@pytest.fixture +def canonical_schema_repo(): + return InMemoryCanonicalSchemaRepository() + + +@pytest.fixture +def service_with_canonical( + mock_session, kg_repo, authz, canonical_schema_repo, tenant_id +): + return KnowledgeGraphService( + session=mock_session, + knowledge_graph_repository=kg_repo, + data_source_repository=InMemoryDataSourceRepository(), + secret_store=InMemorySecretStoreRepository(), + authz=authz, + scope_to_tenant=tenant_id, + probe=RecordingKnowledgeGraphServiceProbe(), + canonical_schema_repository=canonical_schema_repo, + ) + + +@pytest.fixture +def mock_session(): + from unittest.mock import AsyncMock, MagicMock + + session = MagicMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +@pytest.fixture +def kg_repo(): + return InMemoryKnowledgeGraphRepository() + + +@pytest.fixture +def authz(): + return InMemoryAuthorizationProvider() + + +@pytest.fixture +def tenant_id(): + return "tenant-123" + + +@pytest.fixture +def user_id(): + return "user-456" + + +class TestKnowledgeGraphServiceCanonicalSchema: + @pytest.mark.asyncio + async def test_save_ontology_writes_to_canonical_repository( + self, service_with_canonical, canonical_schema_repo, authz, kg_repo, user_id + ): + kg = _make_kg() + kg_repo.seed(kg) + await _grant_kg_edit(authz, kg.id.value, user_id) + config = OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ) + + await service_with_canonical.save_ontology( + user_id=user_id, + kg_id=kg.id.value, + config=config, + ) + + assert len(canonical_schema_repo.replaced) == 1 + assert canonical_schema_repo.replaced[0][0] == kg.id.value + + @pytest.mark.asyncio + async def test_workspace_readiness_uses_canonical_schema( + self, service_with_canonical, canonical_schema_repo, authz, kg_repo, user_id + ): + kg = _make_kg() + kg_repo.seed(kg) + canonical_schema_repo.seed( + kg.id.value, + OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), + ), + ), + ), + ) + await _grant_kg_view(authz, kg.id.value, user_id) + + result = await service_with_canonical.get_workspace_status( + user_id=user_id, + kg_id=kg.id.value, + ) + + assert result is not None + assert result.transition_eligible is True diff --git a/src/api/tests/unit/management/application/test_knowledge_graph_service.py b/src/api/tests/unit/management/application/test_knowledge_graph_service.py index b90c9bf2f..1d7e63e8e 100644 --- a/src/api/tests/unit/management/application/test_knowledge_graph_service.py +++ b/src/api/tests/unit/management/application/test_knowledge_graph_service.py @@ -156,6 +156,13 @@ def _make_kg( return kg +async def _seed_stored_ontology(kg, kg_repo, config: OntologyConfig) -> None: + """Attach ontology to aggregate and persisted JSONB fallback store.""" + kg.set_ontology(config) + kg_repo.seed(kg) + await kg_repo.save_ontology(kg.id.value, config) + + def _make_ds( ds_id: str = "ds-001", kg_id: str = "kg-001", @@ -445,19 +452,17 @@ async def test_workspace_status_includes_mode_readiness_and_session_pointers( ): """Should project mode/readiness flags and default null session pointers.""" kg = _make_kg() - kg.set_ontology( - OntologyConfig( - node_types=(NodeTypeDefinition(label="Repository"),), - edge_types=( - EdgeTypeDefinition( - label="CONTAINS", - source_labels=("Repository",), - target_labels=("Repository",), - ), + ontology_config = OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), ), - ) + ), ) - kg_repo.seed(kg) + await _seed_stored_ontology(kg, kg_repo, ontology_config) await _grant_kg_view(authz, kg.id.value, user_id) result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) @@ -501,25 +506,23 @@ async def test_workspace_status_fails_for_prepopulated_type_without_instances( ): """Should block transition when prepopulated type has zero instances.""" kg = _make_kg() - kg.set_ontology( - OntologyConfig( - node_types=( - NodeTypeDefinition( - label="Repository", - prepopulated=True, - prepopulated_instance_count=0, - ), + ontology_config = OntologyConfig( + node_types=( + NodeTypeDefinition( + label="Repository", + prepopulated=True, + prepopulated_instance_count=0, ), - edge_types=( - EdgeTypeDefinition( - label="CONTAINS", - source_labels=("Repository",), - target_labels=("Repository",), - ), + ), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), ), - ) + ), ) - kg_repo.seed(kg) + await _seed_stored_ontology(kg, kg_repo, ontology_config) await _grant_kg_view(authz, kg.id.value, user_id) result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) @@ -562,19 +565,17 @@ async def test_transition_workspace_requires_edit_permission( self, service, authz, kg_repo, user_id ): kg = _make_kg() - kg.set_ontology( - OntologyConfig( - node_types=(NodeTypeDefinition(label="Repository"),), - edge_types=( - EdgeTypeDefinition( - label="CONTAINS", - source_labels=("Repository",), - target_labels=("Repository",), - ), + ontology_config = OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), ), - ) + ), ) - kg_repo.seed(kg) + await _seed_stored_ontology(kg, kg_repo, ontology_config) await _grant_kg_view(authz, kg.id.value, user_id) with pytest.raises(UnauthorizedError): @@ -588,19 +589,17 @@ async def test_transition_workspace_changes_mode_and_creates_session_pointer( self, service, authz, kg_repo, user_id ): kg = _make_kg() - kg.set_ontology( - OntologyConfig( - node_types=(NodeTypeDefinition(label="Repository"),), - edge_types=( - EdgeTypeDefinition( - label="CONTAINS", - source_labels=("Repository",), - target_labels=("Repository",), - ), + ontology_config = OntologyConfig( + node_types=(NodeTypeDefinition(label="Repository"),), + edge_types=( + EdgeTypeDefinition( + label="CONTAINS", + source_labels=("Repository",), + target_labels=("Repository",), ), - ) + ), ) - kg_repo.seed(kg) + await _seed_stored_ontology(kg, kg_repo, ontology_config) await _grant_kg_edit(authz, kg.id.value, user_id) result = await service.transition_workspace_to_extraction( From bb655db8027ccd29ac3a9652b5b13efe60ede136 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:38:25 -0400 Subject: [PATCH 45/80] feat(dev-ui): KG Manage accessibility and state contracts (#725) (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dev-ui): add graph management parity with shared chat and hybrid panel Align Graph Management step with KG-MANAGE-006–011/016 using a persistent conversation surface, three-mode switcher, and status rail with mode-specific detail. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(dev-ui): add KG manage accessibility and state contracts (#725) Enforce keyboard chat/send behavior, section-specific loading and error UX, and forbidden-action messaging so manage steps stay predictable and accessible. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> --- .../extraction/SharedConversationPanel.vue | 110 +- .../pages/knowledge-graphs/[kgId]/manage.vue | 1088 +++++++++++------ src/dev-ui/app/tests/kgManageState.test.ts | 190 +++ .../knowledge-graph-manage-workspace.test.ts | 286 ++++- src/dev-ui/app/utils/kgGraphManagement.ts | 167 +++ src/dev-ui/app/utils/kgManageState.ts | 180 +++ src/dev-ui/vitest.config.ts | 5 + 7 files changed, 1569 insertions(+), 457 deletions(-) create mode 100644 src/dev-ui/app/tests/kgManageState.test.ts create mode 100644 src/dev-ui/app/utils/kgGraphManagement.ts create mode 100644 src/dev-ui/app/utils/kgManageState.ts diff --git a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue index ca3593441..bbe2edcf5 100644 --- a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue +++ b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue @@ -1,9 +1,9 @@ <script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue' -import { Loader2, RefreshCw } from 'lucide-vue-next' +import { Loader2, RefreshCw, SendHorizontal } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' import { AlertDialog, AlertDialogAction, @@ -14,6 +14,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { handleChatInputKeydown } from '@/utils/kgManageState' interface ConversationEntry { role?: string @@ -31,18 +32,33 @@ const props = withDefaults(defineProps<{ session: ConversationSession | null loading?: boolean clearing?: boolean + sending?: boolean draftMessage?: string activityLines?: string[] + inputPlaceholder?: string + sessionStatusLabel?: string + inputDisabled?: boolean + inputDisabledReason?: string | null + forbidden?: boolean + forbiddenReason?: string | null }>(), { loading: false, clearing: false, + sending: false, draftMessage: '', activityLines: () => [], + inputPlaceholder: 'Describe what you want to do in this graph management session…', + sessionStatusLabel: 'No active session', + inputDisabled: false, + inputDisabledReason: null, + forbidden: false, + forbiddenReason: null, }) const emit = defineEmits<{ refresh: [] clearChat: [] + sendMessage: [message: string] 'update:draftMessage': [value: string] }>() @@ -56,6 +72,18 @@ const combinedTimelineLength = computed( () => messageHistory.value.length + activityTimeline.value.length, ) +const chatInputDisabled = computed( + () => props.loading || props.clearing || props.sending || props.inputDisabled || props.forbidden, +) + +const chatInputHelp = computed(() => { + if (props.forbidden) { + return props.forbiddenReason ?? 'Chat is unavailable because you lack permission for this action.' + } + if (props.inputDisabledReason) return props.inputDisabledReason + return 'Press Enter to send. Shift+Enter adds a new line.' +}) + watch(combinedTimelineLength, async () => { await nextTick() if (timelineRef.value) { @@ -67,17 +95,43 @@ function confirmClearChat() { clearConfirmOpen.value = false emit('clearChat') } + +function sendDraftMessage() { + const trimmed = props.draftMessage.trim() + if (!trimmed || chatInputDisabled.value) return + emit('sendMessage', trimmed) + emit('update:draftMessage', '') +} + +function onChatInputKeydown(event: KeyboardEvent) { + handleChatInputKeydown(event, sendDraftMessage) +} </script> <template> <Card> <CardHeader> - <CardTitle class="text-base">Conversation</CardTitle> - <CardDescription> - Shared conversation feed for {{ modeLabel }} with server-side session resume. - </CardDescription> + <div class="flex flex-wrap items-start justify-between gap-2"> + <div> + <CardTitle class="text-base">Conversation</CardTitle> + <CardDescription> + Shared conversation feed for {{ modeLabel }} with server-side session resume. + </CardDescription> + </div> + <p class="text-xs text-muted-foreground"> + Session: <span class="font-medium text-foreground">{{ sessionStatusLabel }}</span> + </p> + </div> </CardHeader> <CardContent class="space-y-3"> + <div + v-if="forbidden" + class="rounded border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive" + role="alert" + > + {{ forbiddenReason ?? 'You do not have permission to use graph management chat for this knowledge graph.' }} + </div> + <div class="flex items-center justify-between"> <p class="text-xs text-muted-foreground">No local cache: conversation state is server-side only.</p> <Button size="sm" variant="ghost" class="h-7 px-2 text-[11px]" :disabled="loading" @click="emit('refresh')"> @@ -117,21 +171,39 @@ function confirmClearChat() { v-if="messageHistory.length === 0 && activityTimeline.length === 0" class="text-xs text-muted-foreground" > - No messages yet. Use validate/transition actions to drive session activity. + No messages yet. Send a prompt or use validate/transition actions to drive session activity. </p> </div> - <div class="flex items-center gap-2"> - <Input - :model-value="draftMessage" - disabled - placeholder="NDJSON streaming send/receive wiring will attach here." - @update:model-value="(value) => emit('update:draftMessage', value)" - /> - <Button variant="outline" :disabled="clearing || loading" @click="clearConfirmOpen = true"> - <Loader2 v-if="clearing" class="mr-1.5 size-3.5 animate-spin" /> - Clear chat - </Button> + <div class="space-y-2"> + <div class="flex items-start gap-2"> + <Textarea + :model-value="draftMessage" + :disabled="chatInputDisabled" + :placeholder="inputPlaceholder" + class="min-h-20" + aria-label="Graph management chat input" + @update:model-value="(value) => emit('update:draftMessage', value)" + @keydown="onChatInputKeydown" + /> + <Button + variant="default" + class="shrink-0" + :disabled="chatInputDisabled || !draftMessage.trim()" + :title="chatInputHelp" + @click="sendDraftMessage" + > + <Loader2 v-if="sending" class="size-3.5 animate-spin" /> + <SendHorizontal v-else class="size-3.5" /> + </Button> + </div> + <div class="flex flex-wrap items-center justify-between gap-2"> + <p class="text-[11px] text-muted-foreground">{{ chatInputHelp }}</p> + <Button variant="outline" :disabled="clearing || loading || forbidden" @click="clearConfirmOpen = true"> + <Loader2 v-if="clearing" class="mr-1.5 size-3.5 animate-spin" /> + Clear chat + </Button> + </div> </div> </CardContent> </Card> @@ -141,7 +213,7 @@ function confirmClearChat() { <AlertDialogHeader> <AlertDialogTitle>Clear conversation?</AlertDialogTitle> <AlertDialogDescription> - This starts a fresh server-side session timeline for the current mode. + This starts a fresh server-side session timeline while keeping the selected graph management mode. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 5ad56ad70..70b8b528d 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -6,8 +6,21 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import SharedConversationPanel from '@/components/extraction/SharedConversationPanel.vue' +import { + GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS, + GRAPH_MANAGEMENT_MODE_LABELS, + GRAPH_MANAGEMENT_MODE_ORDER, + buildGraphManagementRailItems, + buildGraphManagementStepUrl, + filterRailItemsForMode, + parseGraphManagementModeQuery, + resolveDefaultGraphManagementMode, + resolveRailSelectionForMode, + resolveSharedSessionMode, + type GraphManagementMode, + type GraphManagementRailItemId, +} from '@/utils/kgGraphManagement' import { buildDataSourcesStepUrl, buildMaintainStepUrl, @@ -19,6 +32,15 @@ import { stepStatusTintClass, type WorkspaceStepId, } from '@/utils/kgManageWorkspace' +import { + appendLocalChatMessage, + buildTransitionRestrictionReason, + handleActivatableKeydown, + isForbiddenHttpError, + resolveForbiddenReason, + resolveSectionState, + shouldApplyMutationResult, +} from '@/utils/kgManageState' interface WorkspaceReadinessStatus { has_minimum_entity_types: boolean @@ -108,19 +130,28 @@ const kgIdentity = ref<KnowledgeGraphIdentity | null>(null) const dataSourceCount = ref(0) const maintenanceReadyCount = ref(0) const loading = ref(false) +const workspaceLoadError = ref<string | null>(null) +const workspaceForbidden = ref(false) +const workspaceForbiddenReason = ref<string | null>(null) const validating = ref(false) const transitioning = ref(false) const sessionLoading = ref(false) const sessionHistoryLoading = ref(false) +const sessionLoadError = ref<string | null>(null) +const sessionForbidden = ref(false) +const sessionForbiddenReason = ref<string | null>(null) const clearingChat = ref(false) +const sendingChat = ref(false) const extractionSession = ref<ExtractionSessionResponse | null>(null) const sessionHistory = ref<ExtractionSessionHistoryItem[]>([]) -const extractionTab = ref('extraction-jobs') const draftMessage = ref('') const statusProjection = ref<WorkspaceStatusResponse | null>(null) const mutationLogLoading = ref(false) +const mutationLogLoadError = ref<string | null>(null) const mutationLogRuns = ref<MutationLogRunView[]>([]) const selectedMutationLogRunId = ref<string | null>(null) +const graphManagementMode = ref<GraphManagementMode>('initial-schema-design') +const selectedRailItemId = ref<GraphManagementRailItemId | null>(null) const activeStep = computed(() => parseManageStepQuery(route.query.step)) const showOverview = computed(() => activeStep.value === null) @@ -146,10 +177,54 @@ const modeLabel = computed(() => : 'Schema Bootstrap', ) -const sessionMode = computed<'schema_bootstrap' | 'extraction_operations'>(() => - statusProjection.value?.workspace_mode === 'extraction_operations' - ? 'extraction_operations' - : 'schema_bootstrap', +const stepBadgeLabel = computed(() => { + if (activeStep.value === 'graph-management') { + return graphManagementModeLabel.value + } + return modeLabel.value +}) + +const sharedSessionMode = computed<'schema_bootstrap' | 'extraction_operations'>(() => + resolveSharedSessionMode( + statusProjection.value?.workspace_mode ?? 'schema_bootstrap', + ), +) + +const graphManagementModeLabel = computed( + () => GRAPH_MANAGEMENT_MODE_LABELS[graphManagementMode.value], +) + +const graphManagementInputPlaceholder = computed( + () => GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS[graphManagementMode.value], +) + +const sessionStatusLabel = computed(() => { + if (sessionLoading.value) return 'Loading session' + if (clearingChat.value) return 'Resetting chat' + if (extractionSession.value?.id) { + return `Active · ${extractionSession.value.id.slice(0, 8)}` + } + return 'No active session' +}) + +const graphManagementRailItems = computed(() => { + if (!statusProjection.value) return [] + return buildGraphManagementRailItems({ + workspaceMode: statusProjection.value.workspace_mode, + transitionEligible: statusProjection.value.transition_eligible, + blockingReasonCount: statusProjection.value.readiness.blocking_reasons.length, + prepopulatedGapCount: statusProjection.value.readiness.prepopulated_types_without_instances.length, + sessionUpdatedAt: extractionSession.value?.updated_at ?? null, + hasActiveSession: Boolean(extractionSession.value?.id), + }) +}) + +const visibleRailItems = computed(() => + filterRailItemsForMode(graphManagementRailItems.value, graphManagementMode.value), +) + +const selectedRailItem = computed(() => + visibleRailItems.value.find((item) => item.id === selectedRailItemId.value) ?? null, ) const canTransition = computed(() => @@ -157,6 +232,47 @@ const canTransition = computed(() => && statusProjection.value?.transition_eligible === true, ) +const transitionRestrictionReason = computed(() => + buildTransitionRestrictionReason( + canTransition.value, + statusProjection.value?.readiness.blocking_reasons ?? [], + ), +) + +const workspaceOverviewState = computed(() => + resolveSectionState({ + section: 'workspace-overview', + loading: loading.value, + error: workspaceLoadError.value, + forbidden: workspaceForbidden.value, + forbiddenReason: workspaceForbiddenReason.value, + }), +) + +const mutationLogsSectionState = computed(() => + resolveSectionState({ + section: 'mutation-logs', + loading: mutationLogLoading.value, + error: mutationLogLoadError.value, + forbidden: workspaceForbidden.value, + forbiddenReason: workspaceForbiddenReason.value, + empty: !mutationLogLoading.value + && !mutationLogLoadError.value + && mutationLogRuns.value.length === 0, + emptyActionLabel: 'Refresh runs', + }), +) + +const graphManagementSectionState = computed(() => + resolveSectionState({ + section: 'graph-management', + loading: sessionLoading.value, + error: sessionLoadError.value, + forbidden: sessionForbidden.value, + forbiddenReason: sessionForbiddenReason.value, + }), +) + const selectedMutationLogRun = computed(() => mutationLogRuns.value.find((run) => run.id === selectedMutationLogRunId.value) ?? null, ) @@ -256,15 +372,30 @@ function returnToWorkspaceOverview() { async function loadWorkspaceStatus() { if (!hasTenant.value || !kgId.value) return loading.value = true + workspaceLoadError.value = null try { statusProjection.value = await apiFetch<WorkspaceStatusResponse>( `/management/knowledge-graphs/${kgId.value}/workspace-status`, ) + workspaceForbidden.value = false + workspaceForbiddenReason.value = null } catch (err) { - statusProjection.value = null - toast.error('Failed to load knowledge graph workspace', { - description: extractErrorMessage(err), - }) + if (isForbiddenHttpError(err)) { + workspaceForbidden.value = true + workspaceForbiddenReason.value = resolveForbiddenReason( + err, + 'You do not have permission to view this knowledge graph workspace.', + ) + statusProjection.value = null + } else { + workspaceForbidden.value = false + workspaceForbiddenReason.value = null + statusProjection.value = null + workspaceLoadError.value = extractErrorMessage(err) + toast.error('Failed to load knowledge graph workspace', { + description: workspaceLoadError.value, + }) + } } finally { loading.value = false } @@ -273,6 +404,7 @@ async function loadWorkspaceStatus() { async function loadMutationLogRuns() { if (!hasTenant.value || !kgId.value) return mutationLogLoading.value = true + mutationLogLoadError.value = null try { const dataSources = await apiFetch<DataSourceRef[]>( `/management/knowledge-graphs/${kgId.value}/data-sources`, @@ -307,28 +439,50 @@ async function loadMutationLogRuns() { selectedMutationLogRunId.value = collected[0]?.id ?? null } } catch (err) { + if (isForbiddenHttpError(err)) { + mutationLogLoadError.value = resolveForbiddenReason( + err, + 'You do not have permission to view mutation logs for this graph.', + ) + } else { + mutationLogLoadError.value = extractErrorMessage(err) + toast.error('Failed to load mutation log runs', { + description: mutationLogLoadError.value, + }) + } mutationLogRuns.value = [] selectedMutationLogRunId.value = null - toast.error('Failed to load mutation log runs', { - description: extractErrorMessage(err), - }) } finally { mutationLogLoading.value = false } } async function loadExtractionSession() { - if (!kgId.value) return + if (!kgId.value || activeStep.value !== 'graph-management') return sessionLoading.value = true + sessionLoadError.value = null try { extractionSession.value = await apiFetch<ExtractionSessionResponse>( - `/extraction/knowledge-graphs/${kgId.value}/sessions/${sessionMode.value}/active`, + `/extraction/knowledge-graphs/${kgId.value}/sessions/${sharedSessionMode.value}/active`, ) + sessionForbidden.value = false + sessionForbiddenReason.value = null } catch (err) { extractionSession.value = null - toast.error('Failed to load extraction conversation', { - description: extractErrorMessage(err), - }) + if (isForbiddenHttpError(err)) { + sessionForbidden.value = true + sessionForbiddenReason.value = resolveForbiddenReason( + err, + 'You do not have permission to manage this knowledge graph.', + ) + } else { + sessionForbidden.value = false + sessionForbiddenReason.value = null + sessionLoadError.value = extractErrorMessage(err) + toast.error('Failed to load extraction conversation', { + description: sessionLoadError.value, + }) + } } finally { sessionLoading.value = false } @@ -339,7 +493,7 @@ async function loadSessionHistory() { sessionHistoryLoading.value = true try { const response = await apiFetch<{ sessions: ExtractionSessionHistoryItem[] }>( - `/extraction/knowledge-graphs/${kgId.value}/sessions/${sessionMode.value}/history`, + `/extraction/knowledge-graphs/${kgId.value}/sessions/${sharedSessionMode.value}/history`, ) sessionHistory.value = response.sessions } catch (err) { @@ -352,8 +506,83 @@ async function loadSessionHistory() { } } +function syncGraphManagementState() { + if (activeStep.value !== 'graph-management') return + const fromQuery = parseGraphManagementModeQuery(route.query.gm_mode) + graphManagementMode.value = fromQuery + ?? resolveDefaultGraphManagementMode( + statusProjection.value?.workspace_mode ?? 'schema_bootstrap', + ) + selectedRailItemId.value = resolveRailSelectionForMode( + selectedRailItemId.value, + graphManagementMode.value, + graphManagementRailItems.value, + ) +} + +function setGraphManagementMode(mode: GraphManagementMode) { + graphManagementMode.value = mode + selectedRailItemId.value = resolveRailSelectionForMode( + selectedRailItemId.value, + mode, + graphManagementRailItems.value, + ) + navigateTo(buildGraphManagementStepUrl(kgId.value, mode), { replace: true }) +} + +function selectRailItem(itemId: GraphManagementRailItemId) { + selectedRailItemId.value = itemId +} + +function onRailKeydown(event: KeyboardEvent, itemId: GraphManagementRailItemId) { + handleActivatableKeydown(event, () => selectRailItem(itemId)) +} + +function onStepActionKeydown(event: KeyboardEvent, stepId: WorkspaceStepId) { + handleActivatableKeydown(event, () => openWorkspaceStep(stepId)) +} + +function onModeSwitchKeydown(event: KeyboardEvent, mode: GraphManagementMode) { + handleActivatableKeydown(event, () => setGraphManagementMode(mode)) +} + +function selectMutationLogRun(runId: string) { + selectedMutationLogRunId.value = runId +} + +function onMutationRunKeydown(event: KeyboardEvent, runId: string) { + handleActivatableKeydown(event, () => selectMutationLogRun(runId)) +} + +function sendChatMessage(message: string) { + if (sessionForbidden.value || !shouldApplyMutationResult(sessionForbidden.value)) { + toast.error('Chat unavailable', { + description: sessionForbiddenReason.value + ?? 'You do not have permission to send messages for this knowledge graph.', + }) + return + } + + sendingChat.value = true + try { + const nextHistory = appendLocalChatMessage(extractionSession.value, message) + extractionSession.value = { + ...(extractionSession.value ?? { + id: 'local-session', + runtime_context: {}, + updated_at: new Date().toISOString(), + }), + message_history: nextHistory, + updated_at: new Date().toISOString(), + } + draftMessage.value = '' + } finally { + sendingChat.value = false + } +} + async function validateWorkspace() { - if (!kgId.value) return + if (!kgId.value || workspaceForbidden.value) return validating.value = true try { statusProjection.value = await apiFetch<WorkspaceStatusResponse>( @@ -362,17 +591,26 @@ async function validateWorkspace() { ) toast.success('Workspace validation complete') } catch (err) { - toast.error('Validation failed', { - description: extractErrorMessage(err), - }) + if (isForbiddenHttpError(err)) { + workspaceForbidden.value = true + workspaceForbiddenReason.value = resolveForbiddenReason( + err, + 'You do not have permission to validate this workspace.', + ) + } else { + toast.error('Validation failed', { + description: extractErrorMessage(err), + }) + } } finally { validating.value = false } } async function transitionToExtraction() { - if (!kgId.value || !canTransition.value) return + if (!kgId.value || !canTransition.value || workspaceForbidden.value) return transitioning.value = true + const previousStatus = statusProjection.value try { statusProjection.value = await apiFetch<WorkspaceStatusResponse>( `/management/knowledge-graphs/${kgId.value}/workspace/transition-to-extraction`, @@ -381,9 +619,18 @@ async function transitionToExtraction() { toast.success('Workspace transitioned to extraction operations') await loadExtractionSession() } catch (err) { - toast.error('Transition failed', { - description: extractErrorMessage(err), - }) + statusProjection.value = previousStatus + if (isForbiddenHttpError(err)) { + workspaceForbidden.value = true + workspaceForbiddenReason.value = resolveForbiddenReason( + err, + 'You do not have permission to transition this workspace.', + ) + } else { + toast.error('Transition failed', { + description: extractErrorMessage(err), + }) + } } finally { transitioning.value = false } @@ -391,11 +638,11 @@ async function transitionToExtraction() { async function clearChat() { // Clear chat resets the active extraction session for this knowledge graph. - if (!kgId.value) return + if (!kgId.value || sessionForbidden.value) return clearingChat.value = true try { extractionSession.value = await apiFetch<ExtractionSessionResponse>( - `/extraction/knowledge-graphs/${kgId.value}/sessions/${sessionMode.value}/clear-chat`, + `/extraction/knowledge-graphs/${kgId.value}/sessions/${sharedSessionMode.value}/clear-chat`, { method: 'POST' }, ) toast.success('Extraction chat cleared') @@ -422,6 +669,13 @@ watch(tenantVersion, () => { extractionSession.value = null dataSourceCount.value = 0 maintenanceReadyCount.value = 0 + workspaceLoadError.value = null + workspaceForbidden.value = false + workspaceForbiddenReason.value = null + mutationLogLoadError.value = null + sessionLoadError.value = null + sessionForbidden.value = false + sessionForbiddenReason.value = null loadKgIdentity() loadWorkspaceStatus() loadOverviewMetrics() @@ -430,8 +684,19 @@ watch(tenantVersion, () => { watch( () => statusProjection.value?.workspace_mode, - (mode) => { - if (mode) { + () => { + if (activeStep.value === 'graph-management') { + syncGraphManagementState() + loadExtractionSession() + } + }, +) + +watch( + () => [activeStep.value, route.query.gm_mode] as const, + () => { + if (activeStep.value === 'graph-management') { + syncGraphManagementState() loadExtractionSession() loadSessionHistory() } @@ -445,14 +710,17 @@ watch( <div class="space-y-1"> <div class="flex items-center gap-2"> <h1 class="text-2xl font-semibold tracking-tight">{{ graphHeaderTitle }}</h1> - <Badge v-if="!showOverview" variant="secondary">{{ modeLabel }}</Badge> + <Badge v-if="!showOverview" variant="secondary">{{ stepBadgeLabel }}</Badge> </div> <p class="text-sm text-muted-foreground"> <template v-if="showOverview"> Project workspace for knowledge graph {{ kgId }}. </template> + <template v-else-if="activeStep === 'graph-management'"> + Conversation-first graph management with shared session and mode-specific workspace panels. + </template> <template v-else> - Validate readiness and move from schema bootstrap to extraction operations. + Knowledge-graph scoped mutation run visibility and run metrics. </template> </p> </div> @@ -472,9 +740,34 @@ watch( Select a tenant to manage this workspace. </div> - <div v-else-if="loading" class="flex items-center gap-2 text-sm text-muted-foreground"> + <div + v-else-if="workspaceOverviewState.phase === 'loading'" + class="flex items-center gap-2 text-sm text-muted-foreground" + role="status" + > <Loader2 class="size-4 animate-spin" /> - Loading workspace status... + {{ workspaceOverviewState.message }} + </div> + + <div + v-else-if="workspaceOverviewState.phase === 'forbidden'" + class="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm" + role="alert" + > + <p class="font-medium text-destructive">{{ workspaceOverviewState.title }}</p> + <p class="mt-1 text-muted-foreground">{{ workspaceOverviewState.message }}</p> + </div> + + <div + v-else-if="workspaceOverviewState.phase === 'error'" + class="rounded-lg border border-dashed p-6 text-sm" + role="alert" + > + <p class="font-medium">{{ workspaceOverviewState.title }}</p> + <p class="mt-1 text-muted-foreground">{{ workspaceOverviewState.message }}</p> + <Button class="mt-3" size="sm" variant="outline" @click="loadWorkspaceStatus"> + Retry workspace load + </Button> </div> <template v-else-if="statusProjection"> @@ -517,7 +810,9 @@ watch( <Button class="w-full" variant="outline" + tabindex="0" @click="openWorkspaceStep(card.id)" + @keydown="onStepActionKeydown($event, card.id)" > {{ card.actionLabel }} </Button> @@ -527,7 +822,26 @@ watch( </section> <section v-else-if="activeStep === 'mutation-logs'" class="space-y-4"> - <Card> + <div + v-if="mutationLogsSectionState.phase === 'forbidden'" + class="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm" + role="alert" + > + <p class="font-medium text-destructive">{{ mutationLogsSectionState.title }}</p> + <p class="mt-1 text-muted-foreground">{{ mutationLogsSectionState.message }}</p> + </div> + <div + v-else-if="mutationLogsSectionState.phase === 'error'" + class="rounded-lg border border-dashed p-4 text-sm" + role="alert" + > + <p class="font-medium">{{ mutationLogsSectionState.title }}</p> + <p class="mt-1 text-muted-foreground">{{ mutationLogsSectionState.message }}</p> + <Button class="mt-3" size="sm" variant="outline" @click="loadMutationLogRuns"> + Retry mutation log load + </Button> + </div> + <Card v-else> <CardHeader> <CardTitle class="text-base">MutationLogs</CardTitle> <CardDescription> @@ -542,20 +856,29 @@ watch( Refresh </Button> </div> - <div v-if="mutationLogLoading" class="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground"> + <div v-if="mutationLogLoading" class="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground" role="status"> <Loader2 class="size-3.5 animate-spin" /> - Loading mutation runs... + {{ mutationLogsSectionState.message }} </div> - <div v-else-if="mutationLogRuns.length === 0" class="px-3 py-4 text-xs text-muted-foreground"> - No mutation log runs found for this knowledge graph yet. + <div + v-else-if="mutationLogRuns.length === 0" + class="space-y-2 px-3 py-4 text-xs text-muted-foreground" + > + <p>{{ mutationLogsSectionState.message }}</p> + <Button size="sm" variant="outline" @click="loadMutationLogRuns"> + {{ mutationLogsSectionState.actionLabel ?? 'Refresh runs' }} + </Button> </div> <div v-else class="max-h-64 overflow-auto p-2 space-y-1.5"> <button v-for="run in mutationLogRuns" :key="run.id" - class="w-full rounded border px-2 py-1.5 text-left text-xs transition-colors" + type="button" + tabindex="0" + class="w-full rounded border px-2 py-1.5 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" :class="selectedMutationLogRunId === run.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'" - @click="selectedMutationLogRunId = run.id" + @click="selectMutationLogRun(run.id)" + @keydown="onMutationRunKeydown($event, run.id)" > <p class="font-medium truncate">{{ run.data_source_name }}</p> <p class="text-muted-foreground truncate">{{ new Date(run.started_at).toLocaleString() }}</p> @@ -635,394 +958,355 @@ watch( </Card> </section> - <section v-else class="space-y-6"> - <Card> - <CardHeader> - <CardTitle class="text-base">Mode & Transition Controls</CardTitle> - <CardDescription> - Validate current readiness and transition when eligible. - </CardDescription> - </CardHeader> - <CardContent class="flex flex-wrap gap-2"> - <Button variant="outline" :disabled="validating || transitioning" @click="validateWorkspace"> - <Loader2 v-if="validating" class="mr-1.5 size-3.5 animate-spin" /> - <CheckCircle2 v-else class="mr-1.5 size-3.5" /> - Validate + <section v-else-if="activeStep === 'graph-management'" class="space-y-4"> + <div + v-if="graphManagementSectionState.phase === 'error'" + class="rounded-lg border border-dashed p-4 text-sm" + role="alert" + > + <p class="font-medium">{{ graphManagementSectionState.title }}</p> + <p class="mt-1 text-muted-foreground">{{ graphManagementSectionState.message }}</p> + <Button class="mt-3" size="sm" variant="outline" @click="loadExtractionSession"> + Retry session load </Button> - <Button - :disabled="!canTransition || transitioning || validating" - @click="transitionToExtraction" - > - <Loader2 v-if="transitioning" class="mr-1.5 size-3.5 animate-spin" /> - <PlayCircle v-else class="mr-1.5 size-3.5" /> - Go to Extraction/Mutations - </Button> - <Badge :variant="canTransition ? 'default' : 'secondary'"> - {{ canTransition ? 'Transition eligible' : 'Transition blocked' }} - </Badge> - </CardContent> - </Card> - - <Card> - <CardHeader> - <CardTitle class="text-base">Readiness Results</CardTitle> - <CardDescription> - Bootstrap readiness requirements from workspace validation. - </CardDescription> - </CardHeader> - <CardContent class="space-y-4 text-sm"> - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Bootstrap Progress Checklist - </p> - <div class="space-y-2"> - <div - v-for="item in progressChecklist" - :key="item.id" - class="rounded border px-3 py-2" - > - <div class="flex items-center justify-between"> - <p class="font-medium">{{ item.label }}</p> - <Badge :variant="item.passed ? 'default' : 'destructive'"> - {{ item.passed ? 'Pass' : 'Fail' }} - </Badge> - </div> - <p class="mt-1 text-xs text-muted-foreground"> - {{ item.passed ? item.passDetail : item.failDetail }} - </p> - </div> - </div> - </div> - - <div class="flex items-center justify-between rounded border px-3 py-2"> - <span>Has minimum entity types</span> - <Badge :variant="statusProjection.readiness.has_minimum_entity_types ? 'default' : 'destructive'"> - {{ statusProjection.readiness.has_minimum_entity_types ? 'Yes' : 'No' }} - </Badge> - </div> - <div class="flex items-center justify-between rounded border px-3 py-2"> - <span>Has minimum relationship types</span> - <Badge :variant="statusProjection.readiness.has_minimum_relationship_types ? 'default' : 'destructive'"> - {{ statusProjection.readiness.has_minimum_relationship_types ? 'Yes' : 'No' }} - </Badge> - </div> - <div class="flex items-center justify-between rounded border px-3 py-2"> - <span>Prepopulated types ready</span> - <Badge :variant="statusProjection.readiness.prepopulated_types_ready ? 'default' : 'destructive'"> - {{ statusProjection.readiness.prepopulated_types_ready ? 'Yes' : 'No' }} - </Badge> - </div> + </div> - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Validation Diagnostics - </p> + <Card class="graph-management-controls"> + <CardHeader class="pb-3"> + <CardTitle class="text-base">Graph Management</CardTitle> + <CardDescription> + Shared chat session with mode-specific assistant framing and workspace panels. + </CardDescription> + </CardHeader> + <CardContent class="space-y-3"> <div - v-if="statusProjection.readiness.prepopulated_types_without_instances.length > 0" - class="rounded border border-amber-400/60 bg-amber-50/60 p-2 text-xs dark:border-amber-800 dark:bg-amber-950/20" + class="flex flex-wrap gap-2" + role="tablist" + aria-label="Graph management modes" > - <p class="font-medium text-amber-800 dark:text-amber-300"> - Prepopulated types missing instances - </p> - <ul class="mt-1 list-disc space-y-1 pl-4 text-muted-foreground"> - <li - v-for="typeLabel in statusProjection.readiness.prepopulated_types_without_instances" - :key="typeLabel" - > - {{ typeLabel }} - </li> - </ul> - </div> - - <div v-if="statusProjection.readiness.blocking_reasons.length > 0" class="mt-2 rounded border border-destructive/50 p-3"> - <p class="mb-1 text-xs font-medium text-destructive flex items-center gap-1.5"> - <ShieldAlert class="size-3.5" /> - Blocking reasons - </p> - <ul class="list-disc pl-4 text-xs text-muted-foreground space-y-1"> - <li v-for="reason in statusProjection.readiness.blocking_reasons" :key="reason"> - {{ reason }} - </li> - </ul> + <Button + v-for="mode in GRAPH_MANAGEMENT_MODE_ORDER" + :key="mode" + size="sm" + role="tab" + :aria-selected="graphManagementMode === mode" + tabindex="0" + :variant="graphManagementMode === mode ? 'default' : 'outline'" + @click="setGraphManagementMode(mode)" + @keydown="onModeSwitchKeydown($event, mode)" + > + {{ GRAPH_MANAGEMENT_MODE_LABELS[mode] }} + </Button> </div> - <p - v-else-if="statusProjection.readiness.prepopulated_types_without_instances.length === 0" - class="text-xs text-muted-foreground" - > - No validation diagnostics are currently blocking transition. - </p> - </div> - - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Next Steps - </p> - <ul class="list-disc pl-4 text-xs text-muted-foreground space-y-1"> - <li v-for="step in nextSteps" :key="step">{{ step }}</li> - </ul> - </div> - - </CardContent> - </Card> - - <Card> - <CardHeader> - <CardTitle class="text-base">Session Pointers</CardTitle> - <CardDescription> - Active and recent extraction session references for this knowledge graph. - </CardDescription> - </CardHeader> - <CardContent class="grid gap-2 md:grid-cols-3 text-xs"> - <div class="rounded border px-3 py-2"> - <p class="text-muted-foreground">Active schema bootstrap session</p> - <p class="font-mono break-all mt-1"> - {{ statusProjection.session_pointers.active_schema_bootstrap_session_id ?? 'None' }} - </p> - </div> - <div class="rounded border px-3 py-2"> - <p class="text-muted-foreground">Active extraction operations session</p> - <p class="font-mono break-all mt-1"> - {{ statusProjection.session_pointers.active_extraction_operations_session_id ?? 'None' }} - </p> - </div> - <div class="rounded border px-3 py-2"> - <p class="text-muted-foreground">Most recent completed session</p> - <p class="font-mono break-all mt-1"> - {{ statusProjection.session_pointers.most_recent_completed_session_id ?? 'None' }} - </p> - </div> - </CardContent> - <CardContent class="space-y-3 border-t pt-4"> - <div class="flex items-center justify-between"> - <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Session History - </p> - <Button - size="sm" - variant="ghost" - class="h-6 px-2 text-[10px]" - :disabled="sessionHistoryLoading" - @click="loadSessionHistory" - > - Refresh - </Button> - </div> - <div - v-if="sessionHistoryLoading" - class="flex items-center gap-2 text-xs text-muted-foreground" - > - <Loader2 class="size-3.5 animate-spin" /> - Loading session history... - </div> - <div - v-else-if="sessionHistory.length === 0" - class="rounded border border-dashed px-3 py-4 text-xs text-muted-foreground" - > - No archived or active sessions found for this scope yet. - </div> - <div v-else class="space-y-2"> - <div - v-for="entry in sessionHistory" - :key="entry.id" - class="rounded border px-3 py-2 text-xs" - > - <div class="flex flex-wrap items-center justify-between gap-2"> - <p class="font-mono break-all">{{ entry.id }}</p> - <Badge :variant="entry.is_active ? 'default' : 'secondary'"> - {{ entry.is_active ? 'Active' : 'Archived' }} - </Badge> - </div> - <p class="mt-1 text-muted-foreground"> - Updated {{ new Date(entry.updated_at).toLocaleString() }} - <span v-if="entry.archived_at"> - · Archived {{ new Date(entry.archived_at).toLocaleString() }} - </span> - </p> - <p class="mt-1 text-muted-foreground"> - {{ entry.message_count }} message(s) - · {{ entry.run_metrics.length }} linked run(s) - </p> - <div - v-if="entry.run_metrics.length > 0" - class="mt-2 space-y-1.5 rounded border bg-muted/20 p-2" + <div class="flex flex-wrap items-center gap-2"> + <Badge variant="outline">{{ sessionStatusLabel }}</Badge> + <Button + variant="outline" + size="sm" + :disabled="validating || transitioning || workspaceForbidden" + :title="workspaceForbiddenReason ?? undefined" + @click="validateWorkspace" > - <div - v-for="metric in entry.run_metrics" - :key="metric.sync_run_id" - class="flex flex-wrap items-center justify-between gap-2" - > - <span class="font-mono">{{ metric.mutation_log_id ?? metric.sync_run_id }}</span> - <span class="text-muted-foreground"> - {{ metric.token_usage_total ?? 0 }} tokens · - ${{ (metric.cost_total_usd ?? 0).toFixed(2) }} - </span> - </div> - </div> + <Loader2 v-if="validating" class="mr-1.5 size-3.5 animate-spin" /> + <CheckCircle2 v-else class="mr-1.5 size-3.5" /> + Validate + </Button> + <Badge :variant="canTransition ? 'default' : 'secondary'"> + {{ canTransition ? 'Transition eligible' : 'Transition blocked' }} + </Badge> </div> - </div> - </CardContent> - </Card> + </CardContent> + </Card> - <div class="space-y-4"> <SharedConversationPanel v-model:draft-message="draftMessage" - :mode-label="modeLabel" + :mode-label="graphManagementModeLabel" + :input-placeholder="graphManagementInputPlaceholder" + :session-status-label="sessionStatusLabel" :session="extractionSession" :loading="sessionLoading" :clearing="clearingChat" + :sending="sendingChat" :activity-lines="sessionActivityLines" + :forbidden="sessionForbidden" + :forbidden-reason="sessionForbiddenReason" + :input-disabled="workspaceForbidden" + :input-disabled-reason="workspaceForbiddenReason" @refresh="loadExtractionSession" @clear-chat="clearChat" + @send-message="sendChatMessage" /> - <Card v-if="statusProjection.workspace_mode === 'extraction_operations'"> - <CardHeader> - <CardTitle class="text-base">Operations Workspace</CardTitle> - <CardDescription> - Tabbed controls for extraction jobs, manual mutations, and run/log visibility. - </CardDescription> - </CardHeader> - <CardContent> - <Tabs v-model="extractionTab" class="w-full"> - <TabsList class="grid w-full grid-cols-3"> - <TabsTrigger value="extraction-jobs">Extraction Jobs</TabsTrigger> - <TabsTrigger value="manual-mutations">Manual Mutations</TabsTrigger> - <TabsTrigger value="run-logs">Run/Logs</TabsTrigger> - </TabsList> - <TabsContent value="extraction-jobs" class="mt-3 space-y-2 text-sm"> + <div class="grid gap-4 xl:grid-cols-[280px_1fr]"> + <div + class="graph-management-rail rounded border" + role="listbox" + aria-label="Graph management status and artifacts" + > + <div class="border-b px-3 py-2"> + <p class="text-xs font-medium text-muted-foreground">Status & artifacts</p> + </div> + <div class="space-y-1.5 p-2"> + <button + v-for="item in visibleRailItems" + :key="item.id" + type="button" + role="option" + :aria-selected="selectedRailItemId === item.id" + tabindex="0" + class="w-full rounded border px-2 py-2 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + :class="[ + stepStatusTintClass(item.status), + selectedRailItemId === item.id ? 'border-primary ring-1 ring-primary/30' : 'hover:bg-muted/40', + ]" + @click="selectRailItem(item.id)" + @keydown="onRailKeydown($event, item.id)" + > + <div class="flex items-center justify-between gap-2"> + <p class="font-medium">{{ item.label }}</p> + <Badge variant="outline" class="text-[10px]">{{ item.status }}</Badge> + </div> + <p class="mt-1 text-muted-foreground">{{ item.detailHint }}</p> + <p class="mt-1 text-[10px] text-muted-foreground">Updated {{ item.lastUpdated }}</p> + </button> + </div> + </div> + + <Card class="graph-management-detail"> + <CardHeader class="pb-3"> + <CardTitle class="text-base"> + {{ selectedRailItem?.label ?? 'Workspace detail' }} + </CardTitle> + <CardDescription> + Mode: + <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span> + </CardDescription> + </CardHeader> + <CardContent class="space-y-4 text-sm"> + <template v-if="selectedRailItemId === 'schema-readiness'"> + <div class="rounded border p-3"> + <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> + Bootstrap Progress Checklist + </p> + <div class="space-y-2"> + <div + v-for="item in progressChecklist" + :key="item.id" + class="rounded border px-3 py-2" + > + <div class="flex items-center justify-between"> + <p class="font-medium">{{ item.label }}</p> + <Badge :variant="item.passed ? 'default' : 'destructive'"> + {{ item.passed ? 'Pass' : 'Fail' }} + </Badge> + </div> + <p class="mt-1 text-xs text-muted-foreground"> + {{ item.passed ? item.passDetail : item.failDetail }} + </p> + </div> + </div> + </div> + <div class="flex flex-wrap gap-2"> + <Button variant="outline" :disabled="validating || transitioning || workspaceForbidden" @click="validateWorkspace"> + <Loader2 v-if="validating" class="mr-1.5 size-3.5 animate-spin" /> + <CheckCircle2 v-else class="mr-1.5 size-3.5" /> + Validate + </Button> + <Button + :disabled="!canTransition || transitioning || validating || workspaceForbidden" + :title="transitionRestrictionReason ?? undefined" + @click="transitionToExtraction" + > + <Loader2 v-if="transitioning" class="mr-1.5 size-3.5 animate-spin" /> + <PlayCircle v-else class="mr-1.5 size-3.5" /> + Go to Extraction/Mutations + </Button> + </div> + </template> + + <template v-else-if="selectedRailItemId === 'validation-diagnostics'"> + <div class="rounded border p-3"> + <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> + Validation Diagnostics + </p> + <div + v-if="statusProjection.readiness.prepopulated_types_without_instances.length > 0" + class="rounded border border-amber-400/60 bg-amber-50/60 p-2 text-xs dark:border-amber-800 dark:bg-amber-950/20" + > + <p class="font-medium text-amber-800 dark:text-amber-300"> + Prepopulated types missing instances + </p> + <ul class="mt-1 list-disc space-y-1 pl-4 text-muted-foreground"> + <li + v-for="typeLabel in statusProjection.readiness.prepopulated_types_without_instances" + :key="typeLabel" + > + {{ typeLabel }} + </li> + </ul> + </div> + <div + v-if="statusProjection.readiness.blocking_reasons.length > 0" + class="mt-2 rounded border border-destructive/50 p-3" + > + <p class="mb-1 flex items-center gap-1.5 text-xs font-medium text-destructive"> + <ShieldAlert class="size-3.5" /> + Blocking reasons + </p> + <ul class="list-disc space-y-1 pl-4 text-xs text-muted-foreground"> + <li v-for="reason in statusProjection.readiness.blocking_reasons" :key="reason"> + {{ reason }} + </li> + </ul> + </div> + <p + v-else-if="statusProjection.readiness.prepopulated_types_without_instances.length === 0" + class="text-xs text-muted-foreground" + > + No validation diagnostics are currently blocking transition. + </p> + </div> + <div class="rounded border p-3"> + <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> + Next Steps + </p> + <ul class="list-disc space-y-1 pl-4 text-xs text-muted-foreground"> + <li v-for="step in nextSteps" :key="step">{{ step }}</li> + </ul> + </div> + </template> + + <template v-else-if="selectedRailItemId === 'session-pointers'"> + <div class="grid gap-2 md:grid-cols-3 text-xs"> + <div class="rounded border px-3 py-2"> + <p class="text-muted-foreground">Active schema bootstrap session</p> + <p class="mt-1 break-all font-mono"> + {{ statusProjection.session_pointers.active_schema_bootstrap_session_id ?? 'None' }} + </p> + </div> + <div class="rounded border px-3 py-2"> + <p class="text-muted-foreground">Active extraction operations session</p> + <p class="mt-1 break-all font-mono"> + {{ statusProjection.session_pointers.active_extraction_operations_session_id ?? 'None' }} + </p> + </div> + <div class="rounded border px-3 py-2"> + <p class="text-muted-foreground">Most recent completed session</p> + <p class="mt-1 break-all font-mono"> + {{ statusProjection.session_pointers.most_recent_completed_session_id ?? 'None' }} + </p> + </div> + </div> + <div class="space-y-3 border-t pt-3"> + <div class="flex items-center justify-between"> + <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> + Session History + </p> + <Button + size="sm" + variant="ghost" + class="h-6 px-2 text-[10px]" + :disabled="sessionHistoryLoading" + @click="loadSessionHistory" + > + Refresh + </Button> + </div> + <div + v-if="sessionHistoryLoading" + class="flex items-center gap-2 text-xs text-muted-foreground" + > + <Loader2 class="size-3.5 animate-spin" /> + Loading session history... + </div> + <div + v-else-if="sessionHistory.length === 0" + class="rounded border border-dashed px-3 py-4 text-xs text-muted-foreground" + > + No archived or active sessions found for this scope yet. + </div> + <div v-else class="space-y-2"> + <div + v-for="entry in sessionHistory" + :key="entry.id" + class="rounded border px-3 py-2 text-xs" + > + <div class="flex flex-wrap items-center justify-between gap-2"> + <p class="font-mono break-all">{{ entry.id }}</p> + <Badge :variant="entry.is_active ? 'default' : 'secondary'"> + {{ entry.is_active ? 'Active' : 'Archived' }} + </Badge> + </div> + <p class="mt-1 text-muted-foreground"> + Updated {{ new Date(entry.updated_at).toLocaleString() }} + <span v-if="entry.archived_at"> + · Archived {{ new Date(entry.archived_at).toLocaleString() }} + </span> + </p> + <p class="mt-1 text-muted-foreground"> + {{ entry.message_count }} message(s) + · {{ entry.run_metrics.length }} linked run(s) + </p> + <div + v-if="entry.run_metrics.length > 0" + class="mt-2 space-y-1.5 rounded border bg-muted/20 p-2" + > + <div + v-for="metric in entry.run_metrics" + :key="metric.sync_run_id" + class="flex flex-wrap items-center justify-between gap-2" + > + <span class="font-mono">{{ metric.mutation_log_id ?? metric.sync_run_id }}</span> + <span class="text-muted-foreground"> + {{ metric.token_usage_total ?? 0 }} tokens · + ${{ (metric.cost_total_usd ?? 0).toFixed(2) }} + </span> + </div> + </div> + </div> + </div> + </div> + </template> + + <template v-else-if="graphManagementMode === 'extraction-jobs'"> <p class="text-muted-foreground"> Trigger extraction and maintenance controls from the data sources operations panel. </p> - <Button size="sm" variant="outline" @click="navigateTo('/data-sources')"> - Open Data Source Operations - </Button> - </TabsContent> - <TabsContent value="manual-mutations" class="mt-3 space-y-2 text-sm"> + <div class="flex flex-wrap gap-2"> + <Button + size="sm" + variant="outline" + @click="navigateTo(buildDataSourcesStepUrl(kgId))" + > + Open Data Source Operations + </Button> + <Button + size="sm" + variant="outline" + @click="navigateTo(buildMaintainStepUrl(kgId))" + > + Open Maintain Step + </Button> + </div> + </template> + + <template v-else-if="graphManagementMode === 'one-off-mutations'"> <p class="text-muted-foreground"> Open the mutation editor scoped to this knowledge graph for minor direct edits. </p> <Button size="sm" @click="navigateTo(`/graph/mutations?kg_id=${kgId}&view=editor`)"> Open Manual Mutations </Button> - </TabsContent> - <TabsContent value="run-logs" class="mt-3 space-y-2 text-sm"> - <p class="text-muted-foreground"> - Review sync run history, maintenance outcomes, and operational logs. - </p> - <Button size="sm" variant="outline" @click="navigateTo('/data-sources')"> - Open Run and Log Views - </Button> - <Card class="mt-2"> - <CardHeader> - <CardTitle class="text-sm">MutationLog Browser</CardTitle> - <CardDescription> - Knowledge-graph scoped mutation runs with per-entry operation previews and run metrics. - </CardDescription> - </CardHeader> - <CardContent class="grid gap-3 xl:grid-cols-[280px_1fr]"> - <div class="rounded border"> - <div class="flex items-center justify-between border-b px-3 py-2"> - <p class="text-xs font-medium text-muted-foreground">Runs</p> - <Button size="sm" variant="ghost" class="h-6 px-2 text-[10px]" @click="loadMutationLogRuns"> - Refresh - </Button> - </div> - <div v-if="mutationLogLoading" class="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground"> - <Loader2 class="size-3.5 animate-spin" /> - Loading mutation runs... - </div> - <div v-else-if="mutationLogRuns.length === 0" class="px-3 py-4 text-xs text-muted-foreground"> - No mutation log runs found for this knowledge graph yet. - </div> - <div v-else class="max-h-64 overflow-auto p-2 space-y-1.5"> - <button - v-for="run in mutationLogRuns" - :key="run.id" - class="w-full rounded border px-2 py-1.5 text-left text-xs transition-colors" - :class="selectedMutationLogRunId === run.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'" - @click="selectedMutationLogRunId = run.id" - > - <p class="font-medium truncate">{{ run.data_source_name }}</p> - <p class="text-muted-foreground truncate">{{ new Date(run.started_at).toLocaleString() }}</p> - <div class="mt-1 flex items-center justify-between"> - <Badge variant="outline" class="text-[10px]">{{ run.status }}</Badge> - <span class="font-mono text-[10px] text-muted-foreground">{{ run.mutation_log_id }}</span> - </div> - </button> - </div> - </div> + </template> - <div v-if="selectedMutationLogRun" class="space-y-3 rounded border p-3"> - <div class="flex flex-wrap items-center gap-2"> - <Badge>{{ selectedMutationLogRun.status }}</Badge> - <p class="text-xs text-muted-foreground"> - Data source: - <span class="font-medium text-foreground">{{ selectedMutationLogRun.data_source_name }}</span> - </p> - </div> - <div class="grid gap-2 sm:grid-cols-2"> - <div class="rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground">MutationLog</p> - <p class="mt-1 font-mono break-all">{{ selectedMutationLogRun.mutation_log_id }}</p> - </div> - <div class="rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground">Session</p> - <p class="mt-1 font-mono break-all">{{ selectedMutationLogRun.session_id ?? 'None' }}</p> - </div> - <div class="rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground">Started</p> - <p class="mt-1">{{ new Date(selectedMutationLogRun.started_at).toLocaleString() }}</p> - </div> - <div class="rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground">Completed</p> - <p class="mt-1"> - {{ selectedMutationLogRun.completed_at ? new Date(selectedMutationLogRun.completed_at).toLocaleString() : 'In progress' }} - </p> - </div> - </div> - <div class="grid gap-2 sm:grid-cols-2"> - <div class="rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground flex items-center gap-1.5"> - <Coins class="size-3.5" /> - Token usage - </p> - <p class="mt-1 font-medium">{{ (selectedMutationLogRun.token_usage_total ?? 0).toLocaleString() }}</p> - </div> - <div class="rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground flex items-center gap-1.5"> - <DollarSign class="size-3.5" /> - Cost (USD) - </p> - <p class="mt-1 font-medium">${{ (selectedMutationLogRun.cost_total_usd ?? 0).toFixed(2) }}</p> - </div> - </div> - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium text-muted-foreground">Per-entry operation previews</p> - <div v-if="Object.keys(selectedMutationLogRun.operation_counts).length === 0" class="text-xs text-muted-foreground"> - No operation class counts recorded for this run. - </div> - <div v-else class="space-y-1.5"> - <div - v-for="([opClass, count]) in Object.entries(selectedMutationLogRun.operation_counts)" - :key="opClass" - class="flex items-center justify-between rounded border px-2 py-1.5 text-xs" - > - <span class="font-mono">{{ opClass }}</span> - <Badge variant="secondary">{{ count }}</Badge> - </div> - </div> - </div> - </div> - <div v-else class="rounded border border-dashed p-6 text-sm text-muted-foreground"> - Select a mutation run to view summary and per-entry previews. - </div> - </CardContent> - </Card> - </TabsContent> - </Tabs> - </CardContent> - </Card> - </div> + <template v-else> + <p class="text-xs text-muted-foreground"> + Select a status or artifact item to inspect mode-specific workspace content. + </p> + </template> + </CardContent> + </Card> + </div> </section> </template> </div> diff --git a/src/dev-ui/app/tests/kgManageState.test.ts b/src/dev-ui/app/tests/kgManageState.test.ts new file mode 100644 index 000000000..64fb1fd74 --- /dev/null +++ b/src/dev-ui/app/tests/kgManageState.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest' +import { + SECTION_STATE_MESSAGES, + appendLocalChatMessage, + buildTransitionRestrictionReason, + handleActivatableKeydown, + handleChatInputKeydown, + isForbiddenHttpError, + resolveForbiddenReason, + resolveSectionState, + shouldApplyMutationResult, +} from '../utils/kgManageState' + +describe('KG-MANAGE-017 - chat input keyboard contract', () => { + it('sends on Enter without Shift', () => { + const onSend = vi.fn() + const preventDefault = vi.fn() + const result = handleChatInputKeydown( + { key: 'Enter', shiftKey: false, preventDefault }, + onSend, + ) + + expect(result).toBe('send') + expect(preventDefault).toHaveBeenCalledOnce() + expect(onSend).toHaveBeenCalledOnce() + }) + + it('inserts newline on Shift+Enter without sending', () => { + const onSend = vi.fn() + const preventDefault = vi.fn() + const result = handleChatInputKeydown( + { key: 'Enter', shiftKey: true, preventDefault }, + onSend, + ) + + expect(result).toBe('newline') + expect(preventDefault).not.toHaveBeenCalled() + expect(onSend).not.toHaveBeenCalled() + }) + + it('ignores non-Enter keys', () => { + const onSend = vi.fn() + const preventDefault = vi.fn() + const result = handleChatInputKeydown( + { key: 'a', shiftKey: false, preventDefault }, + onSend, + ) + + expect(result).toBe('ignored') + expect(onSend).not.toHaveBeenCalled() + }) +}) + +describe('KG-MANAGE-018 - keyboard operable step and rail actions', () => { + it('activates step actions on Enter', () => { + const onActivate = vi.fn() + const preventDefault = vi.fn() + const handled = handleActivatableKeydown( + { key: 'Enter', preventDefault }, + onActivate, + ) + + expect(handled).toBe(true) + expect(preventDefault).toHaveBeenCalledOnce() + expect(onActivate).toHaveBeenCalledOnce() + }) + + it('activates step actions on Space', () => { + const onActivate = vi.fn() + const preventDefault = vi.fn() + const handled = handleActivatableKeydown( + { key: ' ', preventDefault }, + onActivate, + ) + + expect(handled).toBe(true) + expect(onActivate).toHaveBeenCalledOnce() + }) + + it('ignores unrelated keys for activatable controls', () => { + const onActivate = vi.fn() + const handled = handleActivatableKeydown( + { key: 'Tab', preventDefault: vi.fn() }, + onActivate, + ) + + expect(handled).toBe(false) + expect(onActivate).not.toHaveBeenCalled() + }) +}) + +describe('KG-MANAGE-019 - section-specific loading, empty, and error states', () => { + it('uses step-specific loading messages', () => { + const overview = resolveSectionState({ + section: 'workspace-overview', + loading: true, + }) + const mutationLogs = resolveSectionState({ + section: 'mutation-logs', + loading: true, + }) + + expect(overview.message).toBe(SECTION_STATE_MESSAGES['workspace-overview'].loading) + expect(mutationLogs.message).toBe(SECTION_STATE_MESSAGES['mutation-logs'].loading) + expect(overview.message).not.toBe(mutationLogs.message) + }) + + it('returns actionable empty states with optional next-step labels', () => { + const state = resolveSectionState({ + section: 'mutation-logs', + empty: true, + emptyActionLabel: 'Refresh runs', + }) + + expect(state.phase).toBe('empty') + expect(state.message).toBe(SECTION_STATE_MESSAGES['mutation-logs'].empty) + expect(state.actionLabel).toBe('Refresh runs') + }) + + it('surfaces section-specific error messaging', () => { + const state = resolveSectionState({ + section: 'graph-management', + error: 'Session service unavailable', + }) + + expect(state.phase).toBe('error') + expect(state.message).toBe('Session service unavailable') + }) +}) + +describe('KG-MANAGE-020 - forbidden and disabled action restrictions', () => { + it('detects forbidden HTTP errors', () => { + expect(isForbiddenHttpError({ statusCode: 403 })).toBe(true) + expect(isForbiddenHttpError(new Error('Forbidden'))).toBe(true) + expect(isForbiddenHttpError({ statusCode: 404 })).toBe(false) + }) + + it('builds explicit forbidden section messaging', () => { + const state = resolveSectionState({ + section: 'graph-management', + forbidden: true, + forbiddenReason: 'You do not have permission to perform this action', + }) + + expect(state.phase).toBe('forbidden') + expect(state.message).toBe('You do not have permission to perform this action') + }) + + it('explains why transition is disabled', () => { + expect( + buildTransitionRestrictionReason(false, ['Missing entity types']), + ).toBe('Transition blocked: Missing entity types') + expect(buildTransitionRestrictionReason(true, [])).toBeNull() + }) + + it('blocks mutation result application when forbidden', () => { + expect(shouldApplyMutationResult(true)).toBe(false) + expect(shouldApplyMutationResult(false)).toBe(true) + }) + + it('extracts forbidden reasons from API errors', () => { + expect( + resolveForbiddenReason( + { data: { detail: 'You do not have permission to perform this action' } }, + 'Access restricted', + ), + ).toBe('You do not have permission to perform this action') + }) +}) + +describe('KG-MANAGE-017 - local chat send helper', () => { + it('appends trimmed user messages to session history', () => { + const history = appendLocalChatMessage( + { message_history: [{ role: 'assistant', content: 'Hello' }] }, + ' Define schema ', + ) + + expect(history).toHaveLength(2) + expect(history[1]).toEqual({ role: 'user', content: 'Define schema' }) + }) + + it('ignores blank chat submissions', () => { + const history = appendLocalChatMessage( + { message_history: [{ role: 'assistant', content: 'Hello' }] }, + ' ', + ) + + expect(history).toHaveLength(1) + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 065facfd7..e020cd2ec 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -13,6 +13,18 @@ import { resolveStepDestination, stepStatusTintClass, } from '../utils/kgManageWorkspace' +import { + GRAPH_MANAGEMENT_MODE_LABELS, + GRAPH_MANAGEMENT_MODE_ORDER, + buildGraphManagementRailItems, + buildGraphManagementStepUrl, + filterRailItemsForMode, + isRailItemValidInMode, + parseGraphManagementModeQuery, + resolveDefaultGraphManagementMode, + resolveRailSelectionForMode, + resolveSharedSessionMode, +} from '../utils/kgGraphManagement' const manageWorkspaceVue = readFileSync( resolve(__dirname, '../pages/knowledge-graphs/[kgId]/manage.vue'), @@ -42,7 +54,7 @@ const baseWorkspaceStatus = { }, } -describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { +describe('Knowledge Graph Manage Workspace - graph management controls', () => { it('loads workspace status projection from management API', () => { expect(manageWorkspaceVue).toContain('/workspace-status') expect(manageWorkspaceVue).toContain('loadWorkspaceStatus') @@ -60,50 +72,18 @@ describe('Knowledge Graph Manage Workspace - mode-aware controls', () => { expect(manageWorkspaceVue).toContain('Go to Extraction/Mutations') }) - it('renders readiness result blocks and blocking reasons list', () => { - expect(manageWorkspaceVue).toContain('Readiness Results') - expect(manageWorkspaceVue).toContain('blocking_reasons') - expect(manageWorkspaceVue).toContain('prepopulated_types_ready') - }) - - it('renders session pointer references for bootstrap and extraction modes', () => { - expect(manageWorkspaceVue).toContain('Session Pointers') - expect(manageWorkspaceVue).toContain('active_schema_bootstrap_session_id') - expect(manageWorkspaceVue).toContain('active_extraction_operations_session_id') - }) - it('loads scoped session history with run metrics after clear chat', () => { expect(manageWorkspaceVue).toContain('loadSessionHistory') - expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/history') + expect(manageWorkspaceVue).toContain('/sessions/${sharedSessionMode.value}/history') expect(manageWorkspaceVue).toContain('sessionHistory') expect(manageWorkspaceVue).toContain('run_metrics') expect(manageWorkspaceVue).toContain('Session History') }) - - - it('uses shared conversation panel for bootstrap and extraction sessions', () => { - expect(manageWorkspaceVue).toContain('SharedConversationPanel') - expect(manageWorkspaceVue).toContain('sessionMode') - expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/active') - }) - - it('supports explicit Clear chat reset for extraction session', () => { - expect(manageWorkspaceVue).toContain('clearChat') - expect(manageWorkspaceVue).toContain('/sessions/${sessionMode.value}/clear-chat') - expect(sharedConversationPanelVue).toContain('Clear chat') - }) - - it('provides tabbed lower operations area for extraction workflows', () => { - expect(manageWorkspaceVue).toContain('Operations Workspace') - expect(manageWorkspaceVue).toContain('TabsTrigger value="extraction-jobs"') - expect(manageWorkspaceVue).toContain('TabsTrigger value="manual-mutations"') - expect(manageWorkspaceVue).toContain('TabsTrigger value="run-logs"') - }) }) describe('Knowledge Graph Manage Workspace - mutation log browser', () => { - it('renders mutation log browser card and scoped run listing', () => { - expect(manageWorkspaceVue).toContain('MutationLog Browser') + it('renders mutation log step with scoped run listing', () => { + expect(manageWorkspaceVue).toContain('MutationLogs') expect(manageWorkspaceVue).toContain('loadMutationLogRuns') expect(manageWorkspaceVue).toContain('/management/knowledge-graphs/${kgId.value}/data-sources') }) @@ -327,4 +307,238 @@ describe('Shared conversation panel - extraction UX contract', () => { expect(sharedConversationPanelVue).toContain('timelineRef') expect(sharedConversationPanelVue).toContain('scrollTop = timelineRef.value.scrollHeight') }) + + it('accepts mode-aware input placeholder and session status props', () => { + expect(sharedConversationPanelVue).toContain('inputPlaceholder') + expect(sharedConversationPanelVue).toContain('sessionStatusLabel') + }) +}) + +describe('KG-MANAGE-006 - graph management conversation-first layout', () => { + it('renders graph management step with shared conversation panel', () => { + expect(manageWorkspaceVue).toContain("activeStep === 'graph-management'") + expect(manageWorkspaceVue).toContain('SharedConversationPanel') + expect(manageWorkspaceVue).toContain('graph-management-controls') + }) + + it('uses one shared session endpoint across UI mode changes', () => { + expect(manageWorkspaceVue).toContain('sharedSessionMode') + expect(manageWorkspaceVue).toContain('/sessions/${sharedSessionMode.value}/active') + expect(manageWorkspaceVue).not.toContain('watch(graphManagementMode') + }) +}) + +describe('KG-MANAGE-007 - graph management modes', () => { + it('supports the three canonical graph management modes', () => { + for (const mode of GRAPH_MANAGEMENT_MODE_ORDER) { + expect(GRAPH_MANAGEMENT_MODE_LABELS[mode]).toBeTruthy() + expect(manageWorkspaceVue).toContain(mode) + } + expect(manageWorkspaceVue).toContain('graphManagementMode') + expect(manageWorkspaceVue).toContain('parseGraphManagementModeQuery') + }) + + it('defaults mode from workspace lifecycle state', () => { + expect(resolveDefaultGraphManagementMode('schema_bootstrap')).toBe('initial-schema-design') + expect(resolveDefaultGraphManagementMode('extraction_operations')).toBe('extraction-jobs') + }) + + it('updates chat placeholder by mode without changing session scope', () => { + expect(manageWorkspaceVue).toContain('graphManagementInputPlaceholder') + expect(manageWorkspaceVue).toContain('GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS') + }) +}) + +describe('KG-MANAGE-008 - hybrid lower panel shared rail', () => { + it('renders persistent status and artifact rail with keyboard selection', () => { + expect(manageWorkspaceVue).toContain('graph-management-rail') + expect(manageWorkspaceVue).toContain('buildGraphManagementRailItems') + expect(manageWorkspaceVue).toContain('role="listbox"') + expect(manageWorkspaceVue).toContain('@keydown') + }) + + it('builds rail items with status and last-updated metadata', () => { + const items = buildGraphManagementRailItems({ + workspaceMode: 'schema_bootstrap', + transitionEligible: false, + blockingReasonCount: 1, + prepopulatedGapCount: 0, + sessionUpdatedAt: '2026-05-22T12:00:00Z', + hasActiveSession: true, + }) + + expect(items.every((item) => item.status && item.lastUpdated && item.label)).toBe(true) + expect(items.find((item) => item.id === 'session-pointers')?.modes).toEqual( + GRAPH_MANAGEMENT_MODE_ORDER, + ) + }) +}) + +describe('KG-MANAGE-009 - hybrid lower panel mode-specific detail', () => { + it('renders mode-specific detail panel content regions', () => { + expect(manageWorkspaceVue).toContain('graph-management-detail') + expect(manageWorkspaceVue).toContain('selectedRailItemId') + expect(manageWorkspaceVue).toContain("selectedRailItemId === 'schema-readiness'") + expect(manageWorkspaceVue).toContain("graphManagementMode === 'extraction-jobs'") + expect(manageWorkspaceVue).toContain("graphManagementMode === 'one-off-mutations'") + }) + + it('filters rail items to the active mode', () => { + const items = buildGraphManagementRailItems({ + workspaceMode: 'extraction_operations', + transitionEligible: true, + blockingReasonCount: 0, + prepopulatedGapCount: 0, + sessionUpdatedAt: null, + hasActiveSession: true, + }) + + expect(filterRailItemsForMode(items, 'extraction-jobs').map((item) => item.id)).toContain( + 'extraction-jobs-setup', + ) + expect(filterRailItemsForMode(items, 'one-off-mutations').map((item) => item.id)).toContain( + 'mutation-authoring', + ) + }) +}) + +describe('KG-MANAGE-010 - schema design parity behavior', () => { + it('exposes schema readiness and validation detail in initial schema design mode', () => { + expect(manageWorkspaceVue).toContain('progressChecklist') + expect(manageWorkspaceVue).toContain('Bootstrap Progress Checklist') + expect(manageWorkspaceVue).toContain('blocking_reasons') + expect(manageWorkspaceVue).toContain('prepopulated_types_without_instances') + }) + + it('keeps validate and transition controls available for schema design work', () => { + expect(manageWorkspaceVue).toContain('validateWorkspace') + expect(manageWorkspaceVue).toContain('transitionToExtraction') + expect(manageWorkspaceVue).toContain('canTransition') + }) +}) + +describe('KG-MANAGE-011 - session reset behavior', () => { + it('supports explicit clear chat reset on the shared session', () => { + expect(manageWorkspaceVue).toContain('clearChat') + expect(manageWorkspaceVue).toContain('/sessions/${sharedSessionMode.value}/clear-chat') + expect(sharedConversationPanelVue).toContain('Clear chat') + }) + + it('keeps graph management mode unchanged after clear chat', () => { + const clearChatBlock = manageWorkspaceVue.match( + /async function clearChat\(\) \{[\s\S]*?\n\}/, + )?.[0] ?? '' + expect(clearChatBlock).toContain('clearChat') + expect(clearChatBlock).not.toContain('graphManagementMode') + }) +}) + +describe('KG-MANAGE-016 - graph management top controls', () => { + it('renders mode switcher, session status, and validation affordance without scrolling', () => { + expect(manageWorkspaceVue).toContain('graph-management-controls') + expect(manageWorkspaceVue).toContain('graphManagementModeLabel') + expect(manageWorkspaceVue).toContain('sessionStatusLabel') + expect(manageWorkspaceVue).toContain('validateWorkspace') + expect(manageWorkspaceVue).toContain('Clear chat') + }) + + it('maps shared session mode from workspace lifecycle without UI mode coupling', () => { + expect(resolveSharedSessionMode('schema_bootstrap')).toBe('schema_bootstrap') + expect(resolveSharedSessionMode('extraction_operations')).toBe('extraction_operations') + }) + + it('preserves rail selection across mode changes when still valid', () => { + const items = buildGraphManagementRailItems({ + workspaceMode: 'extraction_operations', + transitionEligible: true, + blockingReasonCount: 0, + prepopulatedGapCount: 0, + sessionUpdatedAt: '2026-05-22T12:00:00Z', + hasActiveSession: true, + }) + + expect( + resolveRailSelectionForMode('session-pointers', 'extraction-jobs', items), + ).toBe('session-pointers') + expect( + isRailItemValidInMode('schema-readiness', 'extraction-jobs', items), + ).toBe(false) + expect( + resolveRailSelectionForMode('schema-readiness', 'extraction-jobs', items), + ).toBe('session-pointers') + }) + + it('builds graph management URLs with mode query for keyboard navigation', () => { + expect(buildGraphManagementStepUrl('kg-abc', 'one-off-mutations')).toBe( + '/knowledge-graphs/kg-abc/manage?step=graph-management&gm_mode=one-off-mutations', + ) + expect(parseGraphManagementModeQuery('initial-schema-design')).toBe('initial-schema-design') + }) +}) + +describe('KG-MANAGE-017 - chat input keyboard contract', () => { + it('wires Enter-to-send and Shift+Enter newline handling in shared conversation panel', () => { + expect(sharedConversationPanelVue).toContain('handleChatInputKeydown') + expect(sharedConversationPanelVue).toContain('@keydown="onChatInputKeydown"') + expect(sharedConversationPanelVue).toContain('Shift+Enter adds a new line') + expect(sharedConversationPanelVue).toContain("emit('sendMessage'") + expect(manageWorkspaceVue).toContain('@send-message="sendChatMessage"') + }) +}) + +describe('KG-MANAGE-018 - keyboard operable step and rail actions', () => { + it('supports keyboard activation for step card primary actions', () => { + expect(manageWorkspaceVue).toContain('onStepActionKeydown') + expect(manageWorkspaceVue).toContain('handleActivatableKeydown') + expect(manageWorkspaceVue).toContain('@keydown="onStepActionKeydown($event, card.id)"') + }) + + it('supports keyboard activation for graph management rail selection', () => { + expect(manageWorkspaceVue).toContain('onRailKeydown') + expect(manageWorkspaceVue).toContain('role="listbox"') + expect(manageWorkspaceVue).toContain('tabindex="0"') + expect(manageWorkspaceVue).toContain('@keydown="onRailKeydown($event, item.id)"') + }) + + it('exposes keyboard-reachable graph management mode switch tabs', () => { + expect(manageWorkspaceVue).toContain('role="tablist"') + expect(manageWorkspaceVue).toContain('onModeSwitchKeydown') + expect(manageWorkspaceVue).toContain('@keydown="onModeSwitchKeydown($event, mode)"') + }) +}) + +describe('KG-MANAGE-019 - section-specific loading, empty, and error states', () => { + it('uses section state contracts for workspace, graph management, and mutation logs', () => { + expect(manageWorkspaceVue).toContain('resolveSectionState') + expect(manageWorkspaceVue).toContain('workspaceOverviewState') + expect(manageWorkspaceVue).toContain('graphManagementSectionState') + expect(manageWorkspaceVue).toContain('mutationLogsSectionState') + expect(manageWorkspaceVue).toContain('Retry workspace load') + expect(manageWorkspaceVue).toContain('Retry mutation log load') + expect(manageWorkspaceVue).toContain('Retry session load') + }) + + it('renders actionable empty states for mutation log runs', () => { + expect(manageWorkspaceVue).toContain('mutationLogsSectionState.actionLabel') + expect(manageWorkspaceVue).toContain('Refresh runs') + }) +}) + +describe('KG-MANAGE-020 - forbidden and disabled action restrictions', () => { + it('detects forbidden responses and surfaces explicit restriction messaging', () => { + expect(manageWorkspaceVue).toContain('isForbiddenHttpError') + expect(manageWorkspaceVue).toContain('workspaceForbiddenReason') + expect(manageWorkspaceVue).toContain('sessionForbiddenReason') + expect(manageWorkspaceVue).toContain('role="alert"') + expect(manageWorkspaceVue).toContain(':forbidden="sessionForbidden"') + expect(sharedConversationPanelVue).toContain('forbidden?: boolean') + expect(sharedConversationPanelVue).toContain('v-if="forbidden"') + }) + + it('explains disabled transition actions and avoids partial updates on forbidden', () => { + expect(manageWorkspaceVue).toContain('transitionRestrictionReason') + expect(manageWorkspaceVue).toContain('buildTransitionRestrictionReason') + expect(manageWorkspaceVue).toContain('shouldApplyMutationResult') + expect(manageWorkspaceVue).toContain('statusProjection.value = previousStatus') + }) }) diff --git a/src/dev-ui/app/utils/kgGraphManagement.ts b/src/dev-ui/app/utils/kgGraphManagement.ts new file mode 100644 index 000000000..203c6ce7b --- /dev/null +++ b/src/dev-ui/app/utils/kgGraphManagement.ts @@ -0,0 +1,167 @@ +import type { StepStatusLabel } from './kgManageWorkspace' + +export type GraphManagementMode = + | 'initial-schema-design' + | 'extraction-jobs' + | 'one-off-mutations' + +export type GraphManagementRailItemId = + | 'schema-readiness' + | 'validation-diagnostics' + | 'session-pointers' + | 'extraction-jobs-setup' + | 'mutation-authoring' + +export const GRAPH_MANAGEMENT_MODE_ORDER: GraphManagementMode[] = [ + 'initial-schema-design', + 'extraction-jobs', + 'one-off-mutations', +] + +export const GRAPH_MANAGEMENT_MODE_LABELS: Record<GraphManagementMode, string> = { + 'initial-schema-design': 'Initial Schema Design', + 'extraction-jobs': 'Extraction Jobs', + 'one-off-mutations': 'One-off Mutations', +} + +export const GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS: Record<GraphManagementMode, string> = { + 'initial-schema-design': + 'Describe schema goals, entity types, or relationship constraints for this knowledge graph…', + 'extraction-jobs': + 'Ask about extraction job setup, sync runs, or maintenance execution for this graph…', + 'one-off-mutations': + 'Author or preview one-off graph mutations scoped to this knowledge graph…', +} + +export interface GraphManagementRailItem { + id: GraphManagementRailItemId + label: string + status: StepStatusLabel + lastUpdated: string + detailHint: string + modes: GraphManagementMode[] +} + +export interface GraphManagementRailInputs { + workspaceMode: 'schema_bootstrap' | 'extraction_operations' + transitionEligible: boolean + blockingReasonCount: number + prepopulatedGapCount: number + sessionUpdatedAt: string | null + hasActiveSession: boolean +} + +export function parseGraphManagementModeQuery(mode: unknown): GraphManagementMode | null { + if ( + mode === 'initial-schema-design' + || mode === 'extraction-jobs' + || mode === 'one-off-mutations' + ) { + return mode + } + return null +} + +export function resolveDefaultGraphManagementMode( + workspaceMode: 'schema_bootstrap' | 'extraction_operations', +): GraphManagementMode { + return workspaceMode === 'extraction_operations' ? 'extraction-jobs' : 'initial-schema-design' +} + +export function resolveSharedSessionMode( + workspaceMode: 'schema_bootstrap' | 'extraction_operations', +): 'schema_bootstrap' | 'extraction_operations' { + return workspaceMode === 'extraction_operations' ? 'extraction_operations' : 'schema_bootstrap' +} + +export function buildGraphManagementRailItems( + input: GraphManagementRailInputs, +): GraphManagementRailItem[] { + const sessionStamp = input.sessionUpdatedAt ?? 'Not loaded' + const readinessStatus: StepStatusLabel = input.blockingReasonCount > 0 + ? 'needs_attention' + : input.transitionEligible + ? 'ready' + : 'in_progress' + + return [ + { + id: 'schema-readiness', + label: 'Schema readiness', + status: readinessStatus, + lastUpdated: sessionStamp, + detailHint: 'Bootstrap checklist, validate, and transition controls.', + modes: ['initial-schema-design'], + }, + { + id: 'validation-diagnostics', + label: 'Validation diagnostics', + status: input.prepopulatedGapCount > 0 || input.blockingReasonCount > 0 + ? 'needs_attention' + : 'ready', + lastUpdated: sessionStamp, + detailHint: 'Blocking reasons and prepopulated type gaps.', + modes: ['initial-schema-design'], + }, + { + id: 'session-pointers', + label: 'Session pointers', + status: input.hasActiveSession ? 'ready' : 'in_progress', + lastUpdated: sessionStamp, + detailHint: 'Active bootstrap, extraction, and completed session references.', + modes: GRAPH_MANAGEMENT_MODE_ORDER, + }, + { + id: 'extraction-jobs-setup', + label: 'Extraction jobs setup', + status: input.workspaceMode === 'extraction_operations' ? 'ready' : 'blocked', + lastUpdated: sessionStamp, + detailHint: 'Job setup, execution controls, and run context.', + modes: ['extraction-jobs'], + }, + { + id: 'mutation-authoring', + label: 'Mutation authoring', + status: input.workspaceMode === 'extraction_operations' ? 'ready' : 'blocked', + lastUpdated: sessionStamp, + detailHint: 'One-off mutation preview and submit context.', + modes: ['one-off-mutations'], + }, + ] +} + +export function filterRailItemsForMode( + items: GraphManagementRailItem[], + mode: GraphManagementMode, +): GraphManagementRailItem[] { + return items.filter((item) => item.modes.includes(mode)) +} + +export function isRailItemValidInMode( + itemId: GraphManagementRailItemId, + mode: GraphManagementMode, + items: GraphManagementRailItem[], +): boolean { + const item = items.find((candidate) => candidate.id === itemId) + return item?.modes.includes(mode) ?? false +} + +export function resolveRailSelectionForMode( + selectedId: GraphManagementRailItemId | null, + mode: GraphManagementMode, + items: GraphManagementRailItem[], +): GraphManagementRailItemId | null { + const modeItems = filterRailItemsForMode(items, mode) + if (modeItems.length === 0) return null + if (selectedId && isRailItemValidInMode(selectedId, mode, items)) { + return selectedId + } + return modeItems[0]?.id ?? null +} + +export function buildGraphManagementStepUrl( + kgId: string, + mode: GraphManagementMode, +): string { + return `/knowledge-graphs/${encodeURIComponent(kgId)}/manage?step=graph-management&gm_mode=${mode}` +} diff --git a/src/dev-ui/app/utils/kgManageState.ts b/src/dev-ui/app/utils/kgManageState.ts new file mode 100644 index 000000000..2d567463d --- /dev/null +++ b/src/dev-ui/app/utils/kgManageState.ts @@ -0,0 +1,180 @@ +export type ManageSectionId = + | 'workspace-overview' + | 'graph-management' + | 'mutation-logs' + | 'data-sources' + | 'maintain' + +export type SectionPhase = 'loading' | 'empty' | 'error' | 'ready' | 'forbidden' + +export interface SectionStateContract { + phase: SectionPhase + title: string + message: string + actionLabel?: string +} + +export const SECTION_STATE_MESSAGES: Record< + ManageSectionId, + { loading: string; empty: string; error: string; forbidden: string } +> = { + 'workspace-overview': { + loading: 'Loading workspace overview and step readiness…', + empty: 'Workspace overview is unavailable until status loads.', + error: 'Could not load workspace overview for this knowledge graph.', + forbidden: 'You do not have permission to view this workspace overview.', + }, + 'graph-management': { + loading: 'Loading graph management session and workspace panels…', + empty: 'Graph management is ready, but no session activity is loaded yet.', + error: 'Could not load graph management session data.', + forbidden: 'You do not have permission to manage this knowledge graph.', + }, + 'mutation-logs': { + loading: 'Loading mutation log runs for this knowledge graph…', + empty: 'No mutation log runs recorded for this knowledge graph yet.', + error: 'Could not load mutation log runs for this knowledge graph.', + forbidden: 'You do not have permission to view mutation logs for this graph.', + }, + 'data-sources': { + loading: 'Loading data source readiness for this knowledge graph…', + empty: 'Connect a data source to continue workspace setup.', + error: 'Could not load data sources for this knowledge graph.', + forbidden: 'You do not have permission to view data sources for this graph.', + }, + maintain: { + loading: 'Loading maintenance readiness for tracked sources…', + empty: 'No tracked source changes are ready for maintenance.', + error: 'Could not load maintenance readiness for this knowledge graph.', + forbidden: 'You do not have permission to run maintenance for this graph.', + }, +} + +export function isForbiddenHttpError(err: unknown): boolean { + if (err && typeof err === 'object') { + const fetchErr = err as { statusCode?: number; status?: number } + const status = fetchErr.statusCode ?? fetchErr.status + if (status === 403) return true + } + if (err instanceof Error) { + const message = err.message.toLowerCase() + return message.includes('forbidden') || message.includes('403') + } + return false +} + +export function resolveForbiddenReason( + err: unknown, + fallback: string, +): string { + if (err instanceof Error && err.message.trim()) { + return err.message + } + if (err && typeof err === 'object') { + const fetchErr = err as { data?: { detail?: unknown } } + if (typeof fetchErr.data?.detail === 'string' && fetchErr.data.detail.trim()) { + return fetchErr.data.detail + } + } + return fallback +} + +export function resolveSectionState(input: { + section: ManageSectionId + loading?: boolean + error?: string | null + forbidden?: boolean + forbiddenReason?: string | null + empty?: boolean + emptyActionLabel?: string +}): SectionStateContract { + const defaults = SECTION_STATE_MESSAGES[input.section] + + if (input.forbidden) { + return { + phase: 'forbidden', + title: 'Access restricted', + message: input.forbiddenReason?.trim() || defaults.forbidden, + } + } + + if (input.loading) { + return { + phase: 'loading', + title: 'Loading', + message: defaults.loading, + } + } + + if (input.error) { + return { + phase: 'error', + title: 'Unable to load section', + message: input.error, + } + } + + if (input.empty) { + return { + phase: 'empty', + title: 'Nothing to show yet', + message: defaults.empty, + actionLabel: input.emptyActionLabel, + } + } + + return { + phase: 'ready', + title: 'Ready', + message: '', + } +} + +export function handleChatInputKeydown( + event: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'preventDefault'>, + onSend: () => void, +): 'send' | 'newline' | 'ignored' { + if (event.key !== 'Enter') return 'ignored' + if (event.shiftKey) return 'newline' + event.preventDefault() + onSend() + return 'send' +} + +export function handleActivatableKeydown( + event: Pick<KeyboardEvent, 'key' | 'preventDefault'>, + onActivate: () => void, +): boolean { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onActivate() + return true + } + return false +} + +export function buildTransitionRestrictionReason( + canTransition: boolean, + blockingReasons: string[], +): string | null { + if (canTransition) return null + if (blockingReasons.length > 0) { + return `Transition blocked: ${blockingReasons.join('; ')}` + } + return 'Transition blocked until schema bootstrap readiness requirements are met.' +} + +export function shouldApplyMutationResult(forbidden: boolean): boolean { + return !forbidden +} + +export function appendLocalChatMessage( + session: { message_history: Array<{ role?: string; content?: string; message?: string }> } | null, + content: string, +): Array<{ role?: string; content?: string; message?: string }> { + const trimmed = content.trim() + if (!trimmed) return session?.message_history ?? [] + const history = [...(session?.message_history ?? [])] + history.push({ role: 'user', content: trimmed }) + return history +} diff --git a/src/dev-ui/vitest.config.ts b/src/dev-ui/vitest.config.ts index 37e491654..9537e4cf4 100644 --- a/src/dev-ui/vitest.config.ts +++ b/src/dev-ui/vitest.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ test: { environment: 'happy-dom', globals: true, + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/local_modules/**', + ], }, resolve: { alias: { From bd226ec447d4243574c45ab9f7cc4a89bbea57a2 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:38:29 -0400 Subject: [PATCH 46/80] feat(extraction): add docker/podman-backed workload runtime adapters (#732) Introduce container runtime ports and CLI adapters for sticky session and ephemeral worker lifecycle, with settings, factory wiring, and integration tests for local development execution. Co-authored-by: Cursor <cursoragent@cursor.com> --- compose.dev.yaml | 4 + env/api.env | 6 +- src/api/extraction/dependencies.py | 21 ++ src/api/extraction/infrastructure/__init__.py | 13 +- .../container_workload_runtime.py | 236 ++++++++++++++++++ .../workload_runtime_factory.py | 58 +++++ .../workload_runtime_settings.py | 48 ++++ .../container_runtime/__init__.py | 19 ++ .../container_runtime/cli_runtime.py | 87 +++++++ .../container_runtime/factory.py | 30 +++ .../shared_kernel/container_runtime/ports.py | 51 ++++ src/api/tests/integration/conftest.py | 4 + .../tests/integration/extraction/conftest.py | 36 +++ .../test_container_workload_runtime.py | 174 +++++++++++++ .../test_container_workload_runtime.py | 181 ++++++++++++++ .../test_workload_runtime_factory.py | 42 ++++ .../test_workload_runtime_settings.py | 18 ++ .../container_runtime/test_cli_runtime.py | 80 ++++++ 18 files changed, 1106 insertions(+), 2 deletions(-) create mode 100644 src/api/extraction/infrastructure/container_workload_runtime.py create mode 100644 src/api/extraction/infrastructure/workload_runtime_factory.py create mode 100644 src/api/extraction/infrastructure/workload_runtime_settings.py create mode 100644 src/api/shared_kernel/container_runtime/__init__.py create mode 100644 src/api/shared_kernel/container_runtime/cli_runtime.py create mode 100644 src/api/shared_kernel/container_runtime/factory.py create mode 100644 src/api/shared_kernel/container_runtime/ports.py create mode 100644 src/api/tests/integration/extraction/test_container_workload_runtime.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py create mode 100644 src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py diff --git a/compose.dev.yaml b/compose.dev.yaml index e70679ff7..ab8e5bf51 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -5,10 +5,14 @@ services: user: "${UID}:${GID}" environment: UV_CACHE_DIR: /tmp/uv-cache + KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND: container + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE: auto volumes: # Mount the entire app directory (minus venv) for hot-reload - ./src/api:/app:z - /app/.venv + # Allow API process to launch sibling extraction runtime containers locally + - /var/run/docker.sock:/var/run/docker.sock command: - /bin/bash - -c diff --git a/env/api.env b/env/api.env index c909d14cf..781143af9 100644 --- a/env/api.env +++ b/env/api.env @@ -12,4 +12,8 @@ KARTOGRAPH_CORS_ORIGINS=["http://localhost:3000"] KARTOGRAPH_IAM_BOOTSTRAP_ADMIN_USERNAMES='["alice"]' KARTOGRAPH_IAM_SINGLE_TENANT_MODE=false # Generate with uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -KARTOGRAPH_MGMT_ENCRYPTION_KEY="vwN4rUcH-KL-UyJsL8hc6apftRUTovwec6L2M5uF5OE=" \ No newline at end of file +KARTOGRAPH_MGMT_ENCRYPTION_KEY="vwN4rUcH-KL-UyJsL8hc6apftRUTovwec6L2M5uF5OE=" +# Extraction runtime defaults to in-memory adapters. Set backend=container and +# mount /var/run/docker.sock (see compose.dev.yaml) for local container execution. +KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND=memory +KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE=auto \ No newline at end of file diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index 2c6853001..a4771b42c 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -1,5 +1,6 @@ """FastAPI dependencies for Extraction services.""" +from functools import lru_cache from typing import Annotated from fastapi import Depends @@ -14,9 +15,29 @@ ExtractionSessionRunMetricsReader, ExtractionSkillOverrideRepository, ) +from extraction.infrastructure.workload_runtime_factory import ( + create_ephemeral_extraction_worker_launcher, + create_sticky_session_runtime_manager, +) +from extraction.ports.runtime import ( + IEphemeralExtractionWorkerLauncher, + IStickySessionRuntimeManager, +) from infrastructure.database.dependencies import get_write_session +@lru_cache +def get_sticky_session_runtime_manager() -> IStickySessionRuntimeManager: + """Return configured sticky session runtime manager.""" + return create_sticky_session_runtime_manager() + + +@lru_cache +def get_ephemeral_extraction_worker_launcher() -> IEphemeralExtractionWorkerLauncher: + """Return configured ephemeral extraction worker launcher.""" + return create_ephemeral_extraction_worker_launcher() + + def get_extraction_agent_session_service( session: Annotated[AsyncSession, Depends(get_write_session)], ) -> ExtractionAgentSessionService: diff --git a/src/api/extraction/infrastructure/__init__.py b/src/api/extraction/infrastructure/__init__.py index ec40d91d8..f8bfd2360 100644 --- a/src/api/extraction/infrastructure/__init__.py +++ b/src/api/extraction/infrastructure/__init__.py @@ -1,5 +1,9 @@ """Extraction infrastructure adapters and event handlers.""" +from extraction.infrastructure.container_workload_runtime import ( + ContainerEphemeralExtractionWorkerLauncher, + ContainerStickySessionRuntimeManager, +) from extraction.infrastructure.event_handler import ExtractionEventHandler from extraction.infrastructure.repositories import ( ExtractionAgentSessionRepository, @@ -13,14 +17,21 @@ InMemoryStickySessionRuntimeManager, ScopedWorkloadCredentialIssuer, ) +from extraction.infrastructure.workload_runtime_factory import ( + create_ephemeral_extraction_worker_launcher, + create_sticky_session_runtime_manager, +) __all__ = [ "ExtractionEventHandler", "ExtractionAgentSessionRepository", "ExtractionSkillOverrideRepository", "FilesystemExtractionRuntimeContextBuilder", + "ContainerStickySessionRuntimeManager", + "ContainerEphemeralExtractionWorkerLauncher", "InMemoryStickySessionRuntimeManager", "ScopedWorkloadCredentialIssuer", "InMemoryEphemeralExtractionWorkerLauncher", + "create_sticky_session_runtime_manager", + "create_ephemeral_extraction_worker_launcher", ] - diff --git a/src/api/extraction/infrastructure/container_workload_runtime.py b/src/api/extraction/infrastructure/container_workload_runtime.py new file mode 100644 index 000000000..95ccc1f6a --- /dev/null +++ b/src/api/extraction/infrastructure/container_workload_runtime.py @@ -0,0 +1,236 @@ +"""Container-backed extraction workload runtime adapters.""" + +from __future__ import annotations + +import re +from dataclasses import replace +from datetime import UTC, datetime, timedelta + +from ulid import ULID + +from extraction.ports.runtime import ( + EphemeralWorkerLaunchRequest, + EphemeralWorkerLaunchResult, + IEphemeralExtractionWorkerLauncher, + IStickySessionRuntimeManager, + ScopedWorkloadCredentials, + StickySessionRuntimeLease, +) +from shared_kernel.container_runtime.ports import ContainerRunSpec, IContainerRuntime + +_CONTAINER_NAME_SAFE = re.compile(r"[^a-zA-Z0-9_.-]+") + + +def _sanitize_container_name(prefix: str, identifier: str) -> str: + cleaned = _CONTAINER_NAME_SAFE.sub("-", identifier).strip("-") + name = f"{prefix}{cleaned}" + return name[:63].rstrip("-_.") or f"{prefix}runtime" + + +class ContainerStickySessionRuntimeManager(IStickySessionRuntimeManager): + """Sticky runtime manager backed by real container lifecycle operations.""" + + def __init__( + self, + *, + container_runtime: IContainerRuntime, + sticky_image: str, + sticky_command: tuple[str, ...], + session_ttl: timedelta = timedelta(minutes=30), + ) -> None: + self._container_runtime = container_runtime + self._sticky_image = sticky_image + self._sticky_command = sticky_command + self._session_ttl = session_ttl + self._leases: dict[str, StickySessionRuntimeLease] = {} + + def get_or_start_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + ) -> StickySessionRuntimeLease: + now = datetime.now(UTC) + existing = self._leases.get(session_id) + if ( + existing is not None + and existing.expires_at > now + and self._container_runtime.is_running(existing.container_id) + ): + refreshed = replace( + existing, + last_activity_at=now, + expires_at=now + self._session_ttl, + status="active", + ) + self._leases[session_id] = refreshed + return refreshed + + if existing is not None: + self._terminate_container(existing.container_id) + + lease = self._start_runtime( + session_id=session_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + now=now, + ) + self._leases[session_id] = lease + return lease + + def reset_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + ) -> StickySessionRuntimeLease: + existing = self._leases.pop(session_id, None) + if existing is not None: + self._terminate_container(existing.container_id) + return self.get_or_start_runtime( + session_id=session_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + def cleanup_expired(self, *, now: datetime) -> list[str]: + expired_sessions = [ + session_id + for session_id, lease in self._leases.items() + if lease.expires_at <= now + ] + terminated: list[str] = [] + for session_id in expired_sessions: + lease = self._leases.pop(session_id) + self._terminate_container(lease.container_id) + terminated.append(lease.container_id) + return terminated + + def _start_runtime( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + now: datetime, + ) -> StickySessionRuntimeLease: + container_name = _sanitize_container_name("kartograph-sticky-", session_id) + launched = self._container_runtime.run( + ContainerRunSpec( + image=self._sticky_image, + name=container_name, + labels={ + "kartograph.runtime.kind": "sticky", + "kartograph.session_id": session_id, + "kartograph.user_id": user_id, + "kartograph.knowledge_graph_id": knowledge_graph_id, + "kartograph.mode": mode, + }, + command=self._sticky_command, + ) + ) + return StickySessionRuntimeLease( + session_id=session_id, + container_id=launched.container_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + status="active", + last_activity_at=now, + expires_at=now + self._session_ttl, + ) + + def _terminate_container(self, container_id: str) -> None: + if self._container_runtime.is_running(container_id): + self._container_runtime.stop(container_id) + self._container_runtime.remove(container_id, force=True) + + +class ContainerEphemeralExtractionWorkerLauncher(IEphemeralExtractionWorkerLauncher): + """Ephemeral worker launcher backed by real container lifecycle operations.""" + + def __init__( + self, + *, + container_runtime: IContainerRuntime, + worker_image: str, + worker_command: tuple[str, ...], + ) -> None: + self._container_runtime = container_runtime + self._worker_image = worker_image + self._worker_command = worker_command + self._active_workers: dict[str, tuple[EphemeralWorkerLaunchRequest, str]] = {} + + @property + def active_worker_count(self) -> int: + return len(self._active_workers) + + def worker_container_id(self, worker_id: str) -> str | None: + worker = self._active_workers.get(worker_id) + if worker is None: + return None + return worker[1] + + def launch( + self, + *, + request: EphemeralWorkerLaunchRequest, + credentials: ScopedWorkloadCredentials, + ) -> EphemeralWorkerLaunchResult: + required_scopes = { + f"tenant:{request.tenant_id}", + f"knowledge_graph:{request.knowledge_graph_id}", + "workload:extraction", + } + available_scopes = set(credentials.scopes) + if not required_scopes.issubset(available_scopes): + raise ValueError("credentials scope does not satisfy workload requirements") + if credentials.expires_at <= datetime.now(UTC): + raise ValueError("credentials are expired") + + worker_id = str(ULID()) + container_name = _sanitize_container_name("kartograph-worker-", worker_id) + launched = self._container_runtime.run( + ContainerRunSpec( + image=self._worker_image, + name=container_name, + env={ + "KARTOGRAPH_WORKLOAD_TOKEN": credentials.token, + "KARTOGRAPH_TENANT_ID": request.tenant_id, + "KARTOGRAPH_KNOWLEDGE_GRAPH_ID": request.knowledge_graph_id, + "KARTOGRAPH_SESSION_ID": request.session_id, + "KARTOGRAPH_SYNC_RUN_ID": request.sync_run_id, + "KARTOGRAPH_JOB_PACKAGE_ID": request.job_package_id, + }, + labels={ + "kartograph.runtime.kind": "ephemeral", + "kartograph.worker_id": worker_id, + "kartograph.session_id": request.session_id, + "kartograph.sync_run_id": request.sync_run_id, + "kartograph.job_package_id": request.job_package_id, + }, + command=self._worker_command, + ) + ) + self._active_workers[worker_id] = (request, launched.container_id) + return EphemeralWorkerLaunchResult( + worker_id=worker_id, + status="running", + credentials_expires_at=credentials.expires_at, + ) + + def complete_worker(self, worker_id: str) -> None: + worker = self._active_workers.pop(worker_id, None) + if worker is None: + return + container_id = worker[1] + if self._container_runtime.is_running(container_id): + self._container_runtime.stop(container_id) + self._container_runtime.remove(container_id, force=True) diff --git a/src/api/extraction/infrastructure/workload_runtime_factory.py b/src/api/extraction/infrastructure/workload_runtime_factory.py new file mode 100644 index 000000000..176102028 --- /dev/null +++ b/src/api/extraction/infrastructure/workload_runtime_factory.py @@ -0,0 +1,58 @@ +"""Factory helpers for extraction workload runtime adapters.""" + +from __future__ import annotations + +from datetime import timedelta + +from extraction.infrastructure.container_workload_runtime import ( + ContainerEphemeralExtractionWorkerLauncher, + ContainerStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + InMemoryStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime_settings import ( + ExtractionWorkloadRuntimeSettings, + get_extraction_workload_runtime_settings, +) +from extraction.ports.runtime import ( + IEphemeralExtractionWorkerLauncher, + IStickySessionRuntimeManager, +) +from shared_kernel.container_runtime.factory import create_container_runtime + + +def create_sticky_session_runtime_manager( + settings: ExtractionWorkloadRuntimeSettings | None = None, +) -> IStickySessionRuntimeManager: + """Build sticky runtime manager for configured backend.""" + resolved = settings or get_extraction_workload_runtime_settings() + if resolved.backend == "memory": + return InMemoryStickySessionRuntimeManager( + session_ttl=timedelta(minutes=resolved.session_ttl_minutes) + ) + + container_runtime = create_container_runtime(resolved.container_engine) + return ContainerStickySessionRuntimeManager( + container_runtime=container_runtime, + sticky_image=resolved.sticky_image, + sticky_command=resolved.sticky_command, + session_ttl=timedelta(minutes=resolved.session_ttl_minutes), + ) + + +def create_ephemeral_extraction_worker_launcher( + settings: ExtractionWorkloadRuntimeSettings | None = None, +) -> IEphemeralExtractionWorkerLauncher: + """Build ephemeral worker launcher for configured backend.""" + resolved = settings or get_extraction_workload_runtime_settings() + if resolved.backend == "memory": + return InMemoryEphemeralExtractionWorkerLauncher() + + container_runtime = create_container_runtime(resolved.container_engine) + return ContainerEphemeralExtractionWorkerLauncher( + container_runtime=container_runtime, + worker_image=resolved.worker_image, + worker_command=resolved.worker_command, + ) diff --git a/src/api/extraction/infrastructure/workload_runtime_settings.py b/src/api/extraction/infrastructure/workload_runtime_settings.py new file mode 100644 index 000000000..8a99c07d7 --- /dev/null +++ b/src/api/extraction/infrastructure/workload_runtime_settings.py @@ -0,0 +1,48 @@ +"""Settings for extraction workload runtime execution.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ExtractionWorkloadRuntimeSettings(BaseSettings): + """Container and in-memory extraction runtime configuration.""" + + model_config = SettingsConfigDict( + env_prefix="KARTOGRAPH_EXTRACTION_RUNTIME_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + backend: Literal["memory", "container"] = Field(default="memory") + container_engine: Literal["auto", "docker", "podman"] = Field(default="auto") + sticky_image: str = Field(default="docker.io/library/busybox:1.36") + worker_image: str = Field(default="docker.io/library/busybox:1.36") + sticky_command: tuple[str, ...] = Field(default=("sleep", "3600")) + worker_command: tuple[str, ...] = Field(default=("sleep", "3600")) + session_ttl_minutes: int = Field(default=30, ge=1, le=24 * 60) + + @field_validator("sticky_command", "worker_command", mode="before") + @classmethod + def _parse_command(cls, value: object) -> tuple[str, ...]: + if isinstance(value, tuple): + return value + if isinstance(value, list): + return tuple(str(part) for part in value) + if isinstance(value, str): + parts = value.split() + if not parts: + raise ValueError("command must not be empty") + return tuple(parts) + raise TypeError("command must be a string or sequence") + + +@lru_cache +def get_extraction_workload_runtime_settings() -> ExtractionWorkloadRuntimeSettings: + """Get cached extraction workload runtime settings.""" + return ExtractionWorkloadRuntimeSettings() diff --git a/src/api/shared_kernel/container_runtime/__init__.py b/src/api/shared_kernel/container_runtime/__init__.py new file mode 100644 index 000000000..fe3433a33 --- /dev/null +++ b/src/api/shared_kernel/container_runtime/__init__.py @@ -0,0 +1,19 @@ +"""Container runtime abstractions for launching and managing workload containers.""" + +from shared_kernel.container_runtime.cli_runtime import CliContainerRuntime +from shared_kernel.container_runtime.factory import create_container_runtime +from shared_kernel.container_runtime.ports import ( + ContainerRunResult, + ContainerRunSpec, + ContainerRuntimeError, + IContainerRuntime, +) + +__all__ = [ + "CliContainerRuntime", + "ContainerRunResult", + "ContainerRunSpec", + "ContainerRuntimeError", + "IContainerRuntime", + "create_container_runtime", +] diff --git a/src/api/shared_kernel/container_runtime/cli_runtime.py b/src/api/shared_kernel/container_runtime/cli_runtime.py new file mode 100644 index 000000000..7eba19956 --- /dev/null +++ b/src/api/shared_kernel/container_runtime/cli_runtime.py @@ -0,0 +1,87 @@ +"""CLI-backed container runtime using docker or podman.""" + +from __future__ import annotations + +import subprocess +from typing import Final + +from shared_kernel.container_runtime.ports import ( + ContainerRunResult, + ContainerRunSpec, + ContainerRuntimeError, +) + + +class CliContainerRuntime: + """Launch and manage containers through a docker-compatible CLI.""" + + _RUNNING_TEMPLATE: Final[str] = "{{.State.Running}}" + + def __init__(self, *, binary: str) -> None: + self._binary = binary + + def run(self, spec: ContainerRunSpec) -> ContainerRunResult: + command = [self._binary, "run"] + if spec.detach: + command.append("--detach") + if spec.remove_on_exit: + command.append("--rm") + if spec.name is not None: + command.extend(["--name", spec.name]) + for key, value in sorted(spec.labels.items()): + command.extend(["--label", f"{key}={value}"]) + for key, value in sorted(spec.env.items()): + command.extend(["--env", f"{key}={value}"]) + command.append(spec.image) + if spec.command: + command.extend(spec.command) + + stdout = self._execute(command) + container_id = stdout.splitlines()[0].strip() + return ContainerRunResult(container_id=container_id, name=spec.name) + + def stop(self, container_id: str, *, timeout_seconds: int = 10) -> None: + self._execute([self._binary, "stop", "-t", str(timeout_seconds), container_id]) + + def remove(self, container_id: str, *, force: bool = False) -> None: + command = [self._binary, "rm"] + if force: + command.append("-f") + command.append(container_id) + self._execute(command) + + def is_running(self, container_id: str) -> bool: + result = subprocess.run( + [ + self._binary, + "inspect", + "-f", + self._RUNNING_TEMPLATE, + container_id, + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + if "no such" in detail.lower(): + return False + raise ContainerRuntimeError( + f"{self._binary} inspect failed: {detail or 'unknown error'}" + ) + return result.stdout.strip().lower() == "true" + + def _execute(self, command: list[str]) -> str: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise ContainerRuntimeError( + f"{self._binary} {' '.join(command[1:])} failed: {detail}" + ) + return result.stdout diff --git a/src/api/shared_kernel/container_runtime/factory.py b/src/api/shared_kernel/container_runtime/factory.py new file mode 100644 index 000000000..666c17fcf --- /dev/null +++ b/src/api/shared_kernel/container_runtime/factory.py @@ -0,0 +1,30 @@ +"""Factory helpers for container runtime backends.""" + +from __future__ import annotations + +import shutil + +from shared_kernel.container_runtime.cli_runtime import CliContainerRuntime +from shared_kernel.container_runtime.ports import ContainerRuntimeError, IContainerRuntime + + +def create_container_runtime(engine: str = "auto") -> IContainerRuntime: + """Return a CLI container runtime for the requested engine.""" + binary = _resolve_engine_binary(engine) + return CliContainerRuntime(binary=binary) + + +def _resolve_engine_binary(engine: str) -> str: + if engine == "auto": + for candidate in ("docker", "podman"): + if shutil.which(candidate) is not None: + return candidate + raise ContainerRuntimeError("No docker or podman binary found on PATH") + + if engine not in {"docker", "podman"}: + raise ContainerRuntimeError(f"Unsupported container engine: {engine}") + + if shutil.which(engine) is None: + raise ContainerRuntimeError(f"{engine} binary not found on PATH") + + return engine diff --git a/src/api/shared_kernel/container_runtime/ports.py b/src/api/shared_kernel/container_runtime/ports.py new file mode 100644 index 000000000..1870e8923 --- /dev/null +++ b/src/api/shared_kernel/container_runtime/ports.py @@ -0,0 +1,51 @@ +"""Port contracts for container runtime backends.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + + +class ContainerRuntimeError(RuntimeError): + """Raised when a container runtime operation fails.""" + + +@dataclass(frozen=True) +class ContainerRunSpec: + """Launch parameters for a detached container.""" + + image: str + name: str | None = None + env: dict[str, str] = field(default_factory=dict) + labels: dict[str, str] = field(default_factory=dict) + command: tuple[str, ...] | None = None + detach: bool = True + remove_on_exit: bool = False + + +@dataclass(frozen=True) +class ContainerRunResult: + """Result of a successful container launch.""" + + container_id: str + name: str | None + + +class IContainerRuntime(Protocol): + """Backend-neutral container lifecycle operations.""" + + def run(self, spec: ContainerRunSpec) -> ContainerRunResult: + """Launch a container and return its identifier.""" + ... + + def stop(self, container_id: str, *, timeout_seconds: int = 10) -> None: + """Stop a running container.""" + ... + + def remove(self, container_id: str, *, force: bool = False) -> None: + """Remove a stopped container.""" + ... + + def is_running(self, container_id: str) -> bool: + """Return True when the container exists and is running.""" + ... diff --git a/src/api/tests/integration/conftest.py b/src/api/tests/integration/conftest.py index 0cd72cb14..dfbbd80ae 100644 --- a/src/api/tests/integration/conftest.py +++ b/src/api/tests/integration/conftest.py @@ -51,6 +51,10 @@ def pytest_configure(config): "markers", "keycloak: mark test as requiring Keycloak authentication server", ) + config.addinivalue_line( + "markers", + "container_runtime: mark test as requiring docker/podman engine", + ) @pytest.fixture(scope="session") diff --git a/src/api/tests/integration/extraction/conftest.py b/src/api/tests/integration/extraction/conftest.py index 1ce85faff..02f55197c 100644 --- a/src/api/tests/integration/extraction/conftest.py +++ b/src/api/tests/integration/extraction/conftest.py @@ -1,3 +1,39 @@ """Integration test fixtures for Extraction bounded context.""" +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from shared_kernel.container_runtime.factory import create_container_runtime + pytest_plugins = ["tests.integration.management.conftest"] + + +def _engine_available(engine: str) -> bool: + if shutil.which(engine) is None: + return False + result = subprocess.run( + [engine, "info"], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +@pytest.fixture(scope="session") +def container_runtime_engine() -> str: + """Return the container engine binary used for integration tests.""" + for engine in ("docker", "podman"): + if _engine_available(engine): + return engine + pytest.skip("No docker/podman engine available for container runtime tests") + + +@pytest.fixture +def container_runtime(container_runtime_engine: str): + """Provide a CLI container runtime for integration tests.""" + return create_container_runtime(container_runtime_engine) diff --git a/src/api/tests/integration/extraction/test_container_workload_runtime.py b/src/api/tests/integration/extraction/test_container_workload_runtime.py new file mode 100644 index 000000000..516ef4f9e --- /dev/null +++ b/src/api/tests/integration/extraction/test_container_workload_runtime.py @@ -0,0 +1,174 @@ +"""Integration tests for container-backed extraction workload runtime adapters.""" + +from __future__ import annotations + +import time +from datetime import timedelta + +import pytest +from ulid import ULID + +from extraction.infrastructure.container_workload_runtime import ( + ContainerEphemeralExtractionWorkerLauncher, + ContainerStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.ports.runtime import EphemeralWorkerLaunchRequest +from shared_kernel.container_runtime.ports import IContainerRuntime + +pytestmark = [pytest.mark.integration, pytest.mark.container_runtime] + +BUSYBOX_IMAGE = "docker.io/library/busybox:1.36" + + +@pytest.fixture(scope="module", autouse=True) +def ensure_busybox_image(container_runtime_engine: str) -> None: + """Pull the lightweight image used by runtime integration tests.""" + import subprocess + + result = subprocess.run( + [container_runtime_engine, "image", "inspect", BUSYBOX_IMAGE], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pull = subprocess.run( + [container_runtime_engine, "pull", BUSYBOX_IMAGE], + capture_output=True, + text=True, + check=False, + ) + if pull.returncode != 0: + pytest.skip(f"Unable to pull test image {BUSYBOX_IMAGE}: {pull.stderr}") + + +@pytest.fixture +def sticky_manager(container_runtime: IContainerRuntime) -> ContainerStickySessionRuntimeManager: + return ContainerStickySessionRuntimeManager( + container_runtime=container_runtime, + sticky_image=BUSYBOX_IMAGE, + sticky_command=("sleep", "3600"), + session_ttl=timedelta(seconds=30), + ) + + +@pytest.fixture +def worker_launcher( + container_runtime: IContainerRuntime, +) -> ContainerEphemeralExtractionWorkerLauncher: + return ContainerEphemeralExtractionWorkerLauncher( + container_runtime=container_runtime, + worker_image=BUSYBOX_IMAGE, + worker_command=("sleep", "3600"), + ) + + +class TestContainerStickySessionRuntimeIntegration: + def test_happy_path_reuses_sticky_container_until_reset( + self, + sticky_manager: ContainerStickySessionRuntimeManager, + container_runtime: IContainerRuntime, + ) -> None: + first = sticky_manager.get_or_start_runtime( + session_id=f"integration-session-1-{ULID()}", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + second = sticky_manager.get_or_start_runtime( + session_id=first.session_id, + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + + assert first.container_id == second.container_id + assert container_runtime.is_running(first.container_id) + + rotated = sticky_manager.reset_runtime( + session_id=first.session_id, + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + + assert rotated.container_id != first.container_id + assert not container_runtime.is_running(first.container_id) + assert container_runtime.is_running(rotated.container_id) + + sticky_manager.cleanup_expired(now=rotated.expires_at + timedelta(seconds=1)) + assert not container_runtime.is_running(rotated.container_id) + + def test_timeout_cleanup_terminates_expired_sticky_container( + self, + container_runtime: IContainerRuntime, + ) -> None: + manager = ContainerStickySessionRuntimeManager( + container_runtime=container_runtime, + sticky_image=BUSYBOX_IMAGE, + sticky_command=("sleep", "3600"), + session_ttl=timedelta(seconds=2), + ) + lease = manager.get_or_start_runtime( + session_id=f"integration-session-timeout-{ULID()}", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + assert container_runtime.is_running(lease.container_id) + + time.sleep(3) + terminated = manager.cleanup_expired( + now=lease.last_activity_at + timedelta(seconds=3) + ) + + assert terminated == [lease.container_id] + assert not container_runtime.is_running(lease.container_id) + + +class TestContainerEphemeralWorkerIntegration: + def test_happy_path_launches_and_completes_worker( + self, + worker_launcher: ContainerEphemeralExtractionWorkerLauncher, + container_runtime: IContainerRuntime, + ) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=5)) + credentials = issuer.issue(tenant_id="tenant-1", knowledge_graph_id="kg-1") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id=f"integration-session-worker-{ULID()}", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + result = worker_launcher.launch(request=request, credentials=credentials) + container_id = worker_launcher.worker_container_id(result.worker_id) + + assert container_id is not None + assert container_runtime.is_running(container_id) + + worker_launcher.complete_worker(result.worker_id) + + assert worker_launcher.active_worker_count == 0 + assert not container_runtime.is_running(container_id) + + def test_failure_path_rejects_bad_credentials_without_launching_container( + self, + worker_launcher: ContainerEphemeralExtractionWorkerLauncher, + ) -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=5)) + wrong_scope = issuer.issue(tenant_id="tenant-2", knowledge_graph_id="kg-2") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id=f"integration-session-worker-{ULID()}", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + with pytest.raises(ValueError, match="scope"): + worker_launcher.launch(request=request, credentials=wrong_scope) + + assert worker_launcher.active_worker_count == 0 diff --git a/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py b/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py new file mode 100644 index 000000000..1947e8bd1 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py @@ -0,0 +1,181 @@ +"""Unit tests for container-backed extraction workload runtime adapters.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest + +from extraction.infrastructure.container_workload_runtime import ( + ContainerEphemeralExtractionWorkerLauncher, + ContainerStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.ports.runtime import EphemeralWorkerLaunchRequest +from shared_kernel.container_runtime.ports import ContainerRunResult, ContainerRunSpec + + +class TestContainerStickySessionRuntimeManager: + def test_reuses_running_container_for_active_session(self) -> None: + runtime = MagicMock() + runtime.is_running.return_value = True + runtime.run.return_value = ContainerRunResult( + container_id="container-1", + name="kartograph-sticky-session-1", + ) + manager = ContainerStickySessionRuntimeManager( + container_runtime=runtime, + sticky_image="busybox:1.36", + sticky_command=("sleep", "3600"), + session_ttl=timedelta(minutes=30), + ) + + first = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + second = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="extraction_operations", + ) + + assert first.container_id == second.container_id == "container-1" + runtime.run.assert_called_once() + + def test_reset_stops_existing_container_and_starts_new_one(self) -> None: + runtime = MagicMock() + runtime.is_running.return_value = True + runtime.run.side_effect = [ + ContainerRunResult(container_id="container-1", name="name-1"), + ContainerRunResult(container_id="container-2", name="name-2"), + ] + manager = ContainerStickySessionRuntimeManager( + container_runtime=runtime, + sticky_image="busybox:1.36", + sticky_command=("sleep", "3600"), + session_ttl=timedelta(minutes=30), + ) + manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + + rotated = manager.reset_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + + assert rotated.container_id == "container-2" + runtime.stop.assert_called_once_with("container-1") + runtime.remove.assert_called_once_with("container-1", force=True) + + def test_cleanup_expired_terminates_and_returns_container_ids(self) -> None: + runtime = MagicMock() + runtime.is_running.return_value = True + runtime.run.return_value = ContainerRunResult( + container_id="container-1", + name="name-1", + ) + manager = ContainerStickySessionRuntimeManager( + container_runtime=runtime, + sticky_image="busybox:1.36", + sticky_command=("sleep", "3600"), + session_ttl=timedelta(minutes=5), + ) + lease = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + + terminated = manager.cleanup_expired(now=lease.expires_at + timedelta(seconds=1)) + + assert terminated == ["container-1"] + + +class TestContainerEphemeralExtractionWorkerLauncher: + def test_launch_starts_worker_container_without_exposing_credentials(self) -> None: + runtime = MagicMock() + runtime.run.return_value = ContainerRunResult( + container_id="worker-container", + name="kartograph-worker-abc", + ) + launcher = ContainerEphemeralExtractionWorkerLauncher( + container_runtime=runtime, + worker_image="busybox:1.36", + worker_command=("sleep", "3600"), + ) + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + credentials = issuer.issue(tenant_id="tenant-1", knowledge_graph_id="kg-1") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + result = launcher.launch(request=request, credentials=credentials) + + assert result.worker_id + assert result.status == "running" + spec: ContainerRunSpec = runtime.run.call_args.args[0] + assert spec.env["KARTOGRAPH_WORKLOAD_TOKEN"] == credentials.token + + def test_launch_rejects_invalid_credentials(self) -> None: + runtime = MagicMock() + launcher = ContainerEphemeralExtractionWorkerLauncher( + container_runtime=runtime, + worker_image="busybox:1.36", + worker_command=("sleep", "3600"), + ) + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + wrong_scope = issuer.issue(tenant_id="tenant-2", knowledge_graph_id="kg-2") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + + with pytest.raises(ValueError, match="scope"): + launcher.launch(request=request, credentials=wrong_scope) + + def test_complete_worker_terminates_running_container(self) -> None: + runtime = MagicMock() + runtime.is_running.return_value = True + runtime.run.return_value = ContainerRunResult( + container_id="worker-container", + name="kartograph-worker-abc", + ) + launcher = ContainerEphemeralExtractionWorkerLauncher( + container_runtime=runtime, + worker_image="busybox:1.36", + worker_command=("sleep", "3600"), + ) + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + credentials = issuer.issue(tenant_id="tenant-1", knowledge_graph_id="kg-1") + request = EphemeralWorkerLaunchRequest( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + session_id="session-1", + sync_run_id="sync-1", + job_package_id="pkg-1", + ) + result = launcher.launch(request=request, credentials=credentials) + + launcher.complete_worker(result.worker_id) + + runtime.stop.assert_called_once_with("worker-container") + assert launcher.active_worker_count == 0 diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py new file mode 100644 index 000000000..ac44244f9 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py @@ -0,0 +1,42 @@ +"""Unit tests for extraction workload runtime factory.""" + +from __future__ import annotations + +from extraction.infrastructure.container_workload_runtime import ( + ContainerEphemeralExtractionWorkerLauncher, + ContainerStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime import ( + InMemoryEphemeralExtractionWorkerLauncher, + InMemoryStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime_factory import ( + create_ephemeral_extraction_worker_launcher, + create_sticky_session_runtime_manager, +) +from extraction.infrastructure.workload_runtime_settings import ( + ExtractionWorkloadRuntimeSettings, +) + + +class TestWorkloadRuntimeFactory: + def test_memory_backend_returns_in_memory_adapters(self) -> None: + settings = ExtractionWorkloadRuntimeSettings(backend="memory") + + sticky = create_sticky_session_runtime_manager(settings) + worker = create_ephemeral_extraction_worker_launcher(settings) + + assert isinstance(sticky, InMemoryStickySessionRuntimeManager) + assert isinstance(worker, InMemoryEphemeralExtractionWorkerLauncher) + + def test_container_backend_returns_container_adapters(self) -> None: + settings = ExtractionWorkloadRuntimeSettings( + backend="container", + container_engine="docker", + ) + + sticky = create_sticky_session_runtime_manager(settings) + worker = create_ephemeral_extraction_worker_launcher(settings) + + assert isinstance(sticky, ContainerStickySessionRuntimeManager) + assert isinstance(worker, ContainerEphemeralExtractionWorkerLauncher) diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py new file mode 100644 index 000000000..a5902bca3 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py @@ -0,0 +1,18 @@ +"""Unit tests for extraction workload runtime settings.""" + +from __future__ import annotations + +from extraction.infrastructure.workload_runtime_settings import ( + ExtractionWorkloadRuntimeSettings, +) + + +class TestExtractionWorkloadRuntimeSettings: + def test_parses_command_strings_into_tuple(self) -> None: + settings = ExtractionWorkloadRuntimeSettings( + sticky_command="sleep 3600", + worker_command="sleep 120", + ) + + assert settings.sticky_command == ("sleep", "3600") + assert settings.worker_command == ("sleep", "120") diff --git a/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py b/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py new file mode 100644 index 000000000..4e6d4c199 --- /dev/null +++ b/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py @@ -0,0 +1,80 @@ +"""Unit tests for CLI-backed container runtime.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from shared_kernel.container_runtime.cli_runtime import CliContainerRuntime +from shared_kernel.container_runtime.ports import ContainerRunSpec, ContainerRuntimeError + + +class TestCliContainerRuntime: + def test_run_launches_detached_container_with_labels_and_env(self) -> None: + runtime = CliContainerRuntime(binary="docker") + + with patch("shared_kernel.container_runtime.cli_runtime.subprocess.run") as run: + run.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="") + + result = runtime.run( + ContainerRunSpec( + image="busybox:1.36", + name="kartograph-sticky-session-1", + env={"KARTOGRAPH_WORKLOAD_TOKEN": "secret"}, + labels={ + "kartograph.runtime.kind": "sticky", + "kartograph.session_id": "session-1", + }, + command=("sleep", "3600"), + ) + ) + + assert result.container_id == "abc123" + assert result.name == "kartograph-sticky-session-1" + command = run.call_args.args[0] + assert command[0] == "docker" + assert "run" in command + assert "--detach" in command + assert "busybox:1.36" in command + + def test_run_raises_when_cli_fails(self) -> None: + runtime = CliContainerRuntime(binary="docker") + + with patch("shared_kernel.container_runtime.cli_runtime.subprocess.run") as run: + run.return_value = MagicMock( + returncode=125, + stdout="", + stderr="image not found", + ) + + with pytest.raises(ContainerRuntimeError, match="image not found"): + runtime.run(ContainerRunSpec(image="missing:latest")) + + def test_stop_remove_and_is_running_delegate_to_cli(self) -> None: + runtime = CliContainerRuntime(binary="podman") + + with patch("shared_kernel.container_runtime.cli_runtime.subprocess.run") as run: + run.side_effect = [ + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=0, stdout="", stderr=""), + MagicMock(returncode=0, stdout="true\n", stderr=""), + ] + + runtime.stop("abc123", timeout_seconds=5) + runtime.remove("abc123", force=True) + assert runtime.is_running("abc123") is True + + assert run.call_args_list[0].args[0][:3] == ["podman", "stop", "-t"] + + def test_is_running_returns_false_for_missing_container(self) -> None: + runtime = CliContainerRuntime(binary="docker") + + with patch("shared_kernel.container_runtime.cli_runtime.subprocess.run") as run: + run.return_value = MagicMock( + returncode=1, + stdout="", + stderr="Error: No such object: abc123", + ) + + assert runtime.is_running("abc123") is False From c77aa8adcc4f873e3a4aaa101609515be65f78a7 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 12:43:27 -0400 Subject: [PATCH 47/80] feat(manage-ui): harden MutationLogs step with scoped ordering and preview fallback (#728) Add KG-scoped run collection utilities, rich run detail panel separation, explicit no-preview fallback, and paginated mutation-log-entries API skeleton for #721 follow-on. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../presentation/data_sources/models.py | 36 ++++ .../presentation/data_sources/routes.py | 67 ++++++++ .../presentation/test_data_sources_routes.py | 56 +++++++ .../pages/knowledge-graphs/[kgId]/manage.vue | 154 ++++++++++++++---- src/dev-ui/app/tests/kgMutationLogs.test.ts | 120 ++++++++++++++ .../knowledge-graph-manage-workspace.test.ts | 49 +++++- src/dev-ui/app/utils/kgMutationLogs.ts | 100 ++++++++++++ 7 files changed, 546 insertions(+), 36 deletions(-) create mode 100644 src/dev-ui/app/tests/kgMutationLogs.test.ts create mode 100644 src/dev-ui/app/utils/kgMutationLogs.ts diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index b2508a79c..903d494aa 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -246,6 +246,33 @@ class SyncRunLogsResponse(BaseModel): ) +class MutationLogEntryPreviewResponse(BaseModel): + """Single mutation-log entry preview for a sync run.""" + + line_number: int = Field(..., description="1-based line number in the mutation log") + operation_class: str = Field(..., description="Operation class for this entry") + summary: str = Field(..., description="Human-readable preview summary for this entry") + + +class MutationLogEntryPreviewPageResponse(BaseModel): + """Paginated mutation-log entry previews for a sync run.""" + + entries: list[MutationLogEntryPreviewResponse] = Field( + default_factory=list, + description="Preview entries for the requested page", + ) + total: int = Field(..., description="Total preview entries available for this run") + offset: int = Field(..., description="Zero-based offset of this page") + limit: int = Field(..., description="Maximum entries requested for this page") + preview_available: bool = Field( + ..., + description=( + "False when detailed mutation-log entry previews are not yet stored " + "or cannot be retrieved for this run" + ), + ) + + class DiffChangedFileResponse(BaseModel): """Single changed file entry in a commit diff summary.""" @@ -299,6 +326,10 @@ class SyncRunResponse(BaseModel): mutation_log_id: str | None = Field( None, description="Associated mutation log run ID when available" ) + knowledge_graph_id: str | None = Field( + None, + description="Knowledge graph scope for this mutation run when available", + ) session_id: str | None = Field( None, description="Extraction session ID associated with this mutation run" ) @@ -341,6 +372,11 @@ def from_domain(cls, run: DataSourceSyncRun) -> SyncRunResponse: if run.mutation_log_run is not None else None ), + knowledge_graph_id=( + run.mutation_log_run.knowledge_graph_id + if run.mutation_log_run is not None + else None + ), session_id=( run.mutation_log_run.session_id if run.mutation_log_run is not None diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index c35ab68c7..dc8c64560 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -29,6 +29,7 @@ DataSourceWithSyncResponse, RunControlAction, RunControlResponse, + MutationLogEntryPreviewPageResponse, SyncRunLogsResponse, SyncRunResponse, UpdateDataSourceRequest, @@ -704,3 +705,69 @@ async def get_sync_run_logs( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to fetch sync run logs", ) + + +@router.get( + "/data-sources/{ds_id}/sync-runs/{run_id}/mutation-log-entries", + status_code=status.HTTP_200_OK, +) +async def list_mutation_log_entry_previews( + ds_id: str, + run_id: str, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[DataSourceService, Depends(get_data_source_service)], + sync_run_repo: Annotated[ + IDataSourceSyncRunRepository, Depends(get_sync_run_repository) + ], + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 20, +) -> MutationLogEntryPreviewPageResponse: + """List paginated mutation-log entry previews for a sync run. + + Returns an empty page with ``preview_available=false`` until mutation-log + storage is wired for per-entry retrieval (#721 follow-on). + """ + try: + ds = await service.get( + user_id=current_user.user_id.value, + ds_id=ds_id, + ) + + if ds is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Data source not found", + ) + + sync_run = await sync_run_repo.get_by_id(run_id) + + if sync_run is None or sync_run.data_source_id != ds_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Sync run not found", + ) + + if sync_run.mutation_log_run is None: + return MutationLogEntryPreviewPageResponse( + entries=[], + total=0, + offset=offset, + limit=limit, + preview_available=False, + ) + + return MutationLogEntryPreviewPageResponse( + entries=[], + total=0, + offset=offset, + limit=limit, + preview_available=False, + ) + + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch mutation log entry previews", + ) diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 62e1a9f53..b7e613ade 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -509,6 +509,7 @@ def test_list_sync_runs_includes_mutation_log_run_preview_fields( assert response.status_code == status.HTTP_200_OK payload = response.json()[0] assert payload["mutation_log_id"] == "mlog-preview-1" + assert payload["knowledge_graph_id"] == sample_data_source.knowledge_graph_id assert payload["session_id"] == "sess-preview-1" assert payload["actor_id"] == "actor-preview-1" assert payload["operation_counts"] == { @@ -534,6 +535,61 @@ def test_list_sync_runs_returns_404_when_ds_not_found( mock_sync_run_repo.find_by_data_source.assert_not_called() +class TestMutationLogEntryPreviewRoutes: + """Tests for GET /management/data-sources/{ds_id}/sync-runs/{run_id}/mutation-log-entries.""" + + def test_list_mutation_log_entries_returns_paginated_skeleton_when_storage_unavailable( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_sync_run_repo: AsyncMock, + sample_data_source: DataSource, + sample_sync_run: DataSourceSyncRun, + ) -> None: + sample_sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="mlog-preview-1", + knowledge_graph_id=sample_data_source.knowledge_graph_id, + session_id="sess-preview-1", + actor_id="actor-preview-1", + started_at=sample_sync_run.started_at, + operation_counts={"create_node": 3}, + ) + mock_ds_service.get.return_value = sample_data_source + mock_sync_run_repo.get_by_id.return_value = sample_sync_run + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/sync-runs/" + f"{sample_sync_run.id}/mutation-log-entries?offset=0&limit=20" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload == { + "entries": [], + "total": 0, + "offset": 0, + "limit": 20, + "preview_available": False, + } + + def test_list_mutation_log_entries_returns_404_when_run_missing( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_sync_run_repo: AsyncMock, + sample_data_source: DataSource, + ) -> None: + mock_ds_service.get.return_value = sample_data_source + mock_sync_run_repo.get_by_id.return_value = None + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/sync-runs/" + "missing-run/mutation-log-entries" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + class TestRunControlRoutes: """Tests for POST /management/data-sources/{ds_id}/run-controls/{action}.""" diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 70b8b528d..21342aeb1 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -41,6 +41,16 @@ import { resolveSectionState, shouldApplyMutationResult, } from '@/utils/kgManageState' +import { + buildMutationLogEntryPreviewUrl, + collectScopedMutationLogRuns, + hasMutationLogEntryPreviewPage, + MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE, + MUTATION_LOG_NO_PREVIEW_MESSAGE, + resolveDefaultSelectedMutationLogRunId, + type MutationLogEntryPreviewPage, + type MutationLogRunRecord, +} from '@/utils/kgMutationLogs' interface WorkspaceReadinessStatus { has_minimum_entity_types: boolean @@ -77,20 +87,8 @@ interface DataSourceRef { tracked_branch_head_commit?: string | null } -interface MutationLogRunView { - id: string - data_source_id: string +interface MutationLogRunView extends MutationLogRunRecord { data_source_name: string - status: string - started_at: string - completed_at: string | null - mutation_log_id: string | null - session_id: string | null - actor_id: string | null - operation_counts: Record<string, number> - token_usage_total: number | null - cost_total_usd: number | null - error: string | null } interface ExtractionSessionResponse { @@ -152,6 +150,9 @@ const mutationLogRuns = ref<MutationLogRunView[]>([]) const selectedMutationLogRunId = ref<string | null>(null) const graphManagementMode = ref<GraphManagementMode>('initial-schema-design') const selectedRailItemId = ref<GraphManagementRailItemId | null>(null) +const mutationLogEntryPreviewLoading = ref(false) +const mutationLogEntryPreviewPage = ref<MutationLogEntryPreviewPage | null>(null) +const mutationLogEntryPreviewOffset = ref(0) const activeStep = computed(() => parseManageStepQuery(route.query.step)) const showOverview = computed(() => activeStep.value === null) @@ -410,34 +411,28 @@ async function loadMutationLogRuns() { `/management/knowledge-graphs/${kgId.value}/data-sources`, ) - const collected: MutationLogRunView[] = [] + const runsByDataSourceId: Record<string, MutationLogRunRecord[]> = {} for (const ds of dataSources) { try { - const runs = await apiFetch<MutationLogRunView[]>( + runsByDataSourceId[ds.id] = await apiFetch<MutationLogRunRecord[]>( `/management/data-sources/${ds.id}/sync-runs`, ) - for (const run of runs) { - if (!run.mutation_log_id) continue - collected.push({ - ...run, - data_source_name: ds.name, - }) - } } catch { - // Keep page resilient when one data source run list fails. + runsByDataSourceId[ds.id] = [] } } - collected.sort( - (a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), - ) + const collected = collectScopedMutationLogRuns( + kgId.value, + dataSources, + runsByDataSourceId, + ) as MutationLogRunView[] + mutationLogRuns.value = collected - if ( - !selectedMutationLogRunId.value - || !collected.some((run) => run.id === selectedMutationLogRunId.value) - ) { - selectedMutationLogRunId.value = collected[0]?.id ?? null - } + selectedMutationLogRunId.value = resolveDefaultSelectedMutationLogRunId( + collected, + selectedMutationLogRunId.value, + ) } catch (err) { if (isForbiddenHttpError(err)) { mutationLogLoadError.value = resolveForbiddenReason( @@ -452,11 +447,48 @@ async function loadMutationLogRuns() { } mutationLogRuns.value = [] selectedMutationLogRunId.value = null + mutationLogEntryPreviewPage.value = null } finally { mutationLogLoading.value = false } } +async function loadMutationLogEntryPreviews(offset = 0) { + const run = selectedMutationLogRun.value + if (!run) { + mutationLogEntryPreviewPage.value = null + mutationLogEntryPreviewOffset.value = 0 + return + } + + mutationLogEntryPreviewLoading.value = true + try { + mutationLogEntryPreviewPage.value = await apiFetch<MutationLogEntryPreviewPage>( + buildMutationLogEntryPreviewUrl( + run.data_source_id, + run.id, + offset, + MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE, + ), + ) + mutationLogEntryPreviewOffset.value = offset + } catch (err) { + mutationLogEntryPreviewPage.value = { + entries: [], + total: 0, + offset, + limit: MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE, + preview_available: false, + } + mutationLogEntryPreviewOffset.value = offset + toast.error('Failed to load mutation log entry previews', { + description: extractErrorMessage(err), + }) + } finally { + mutationLogEntryPreviewLoading.value = false + } +} + async function loadExtractionSession() { if (!kgId.value || activeStep.value !== 'graph-management') return sessionLoading.value = true @@ -702,6 +734,10 @@ watch( } }, ) + +watch(selectedMutationLogRunId, () => { + loadMutationLogEntryPreviews(0) +}) </script> <template> @@ -891,6 +927,7 @@ watch( </div> <div v-if="selectedMutationLogRun" class="space-y-3 rounded border p-3"> + <p class="text-xs font-medium text-muted-foreground">Run summary</p> <div class="flex flex-wrap items-center gap-2"> <Badge>{{ selectedMutationLogRun.status }}</Badge> <p class="text-xs text-muted-foreground"> @@ -935,7 +972,7 @@ watch( </div> </div> <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium text-muted-foreground">Per-entry operation previews</p> + <p class="mb-2 text-xs font-medium text-muted-foreground">Operation class counts</p> <div v-if="Object.keys(selectedMutationLogRun.operation_counts).length === 0" class="text-xs text-muted-foreground"> No operation class counts recorded for this run. </div> @@ -950,6 +987,57 @@ watch( </div> </div> </div> + <div class="rounded border p-3"> + <div class="mb-2 flex items-center justify-between gap-2"> + <p class="text-xs font-medium text-muted-foreground">Per-entry operation previews</p> + <div + v-if="hasMutationLogEntryPreviewPage(mutationLogEntryPreviewPage)" + class="flex items-center gap-1" + > + <Button + size="sm" + variant="ghost" + class="h-6 px-2 text-[10px]" + :disabled="mutationLogEntryPreviewLoading || mutationLogEntryPreviewOffset === 0" + @click="loadMutationLogEntryPreviews(mutationLogEntryPreviewOffset - MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE)" + > + Previous + </Button> + <Button + size="sm" + variant="ghost" + class="h-6 px-2 text-[10px]" + :disabled="mutationLogEntryPreviewLoading || (mutationLogEntryPreviewPage?.offset ?? 0) + (mutationLogEntryPreviewPage?.entries.length ?? 0) >= (mutationLogEntryPreviewPage?.total ?? 0)" + @click="loadMutationLogEntryPreviews(mutationLogEntryPreviewOffset + MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE)" + > + Next + </Button> + </div> + </div> + <div v-if="mutationLogEntryPreviewLoading" class="flex items-center gap-2 text-xs text-muted-foreground"> + <Loader2 class="size-3.5 animate-spin" /> + Loading entry previews... + </div> + <div + v-else-if="!hasMutationLogEntryPreviewPage(mutationLogEntryPreviewPage)" + class="rounded border border-dashed px-3 py-4 text-xs text-muted-foreground" + > + {{ MUTATION_LOG_NO_PREVIEW_MESSAGE }} + </div> + <div v-else class="space-y-1.5"> + <div + v-for="entry in mutationLogEntryPreviewPage?.entries ?? []" + :key="`${entry.line_number}-${entry.operation_class}`" + class="rounded border px-2 py-1.5 text-xs" + > + <div class="flex items-center justify-between gap-2"> + <span class="font-mono">{{ entry.operation_class }}</span> + <span class="text-[10px] text-muted-foreground">Line {{ entry.line_number }}</span> + </div> + <p class="mt-1 text-muted-foreground">{{ entry.summary }}</p> + </div> + </div> + </div> </div> <div v-else class="rounded border border-dashed p-6 text-sm text-muted-foreground"> Select a mutation run to view summary and per-entry previews. diff --git a/src/dev-ui/app/tests/kgMutationLogs.test.ts b/src/dev-ui/app/tests/kgMutationLogs.test.ts new file mode 100644 index 000000000..a02446294 --- /dev/null +++ b/src/dev-ui/app/tests/kgMutationLogs.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + MUTATION_LOG_NO_PREVIEW_MESSAGE, + buildMutationLogEntryPreviewUrl, + collectScopedMutationLogRuns, + hasMutationLogEntryPreviewPage, + isMutationLogRunForKnowledgeGraph, + resolveDefaultSelectedMutationLogRunId, + sortMutationLogRunsNewestFirst, +} from '../utils/kgMutationLogs' + +const kgId = 'kg-target' + +function makeRun(overrides: Partial<ReturnType<typeof baseRun>> = {}) { + return { ...baseRun(), ...overrides } +} + +function baseRun() { + return { + id: 'run-1', + data_source_id: 'ds-1', + status: 'completed', + started_at: '2026-05-20T10:00:00Z', + completed_at: '2026-05-20T10:05:00Z', + mutation_log_id: 'mlog-1', + knowledge_graph_id: kgId, + session_id: 'sess-1', + actor_id: 'actor-1', + operation_counts: { create_node: 2 }, + token_usage_total: 100, + cost_total_usd: 0.5, + error: null, + } +} + +describe('KG-MANAGE-012 - graph-scoped mutation run list', () => { + it('includes only runs with mutation logs scoped to the selected knowledge graph', () => { + const runs = collectScopedMutationLogRuns( + kgId, + [{ id: 'ds-1', name: 'Source A' }], + { + 'ds-1': [ + makeRun({ id: 'run-a', mutation_log_id: 'mlog-a' }), + makeRun({ id: 'run-b', mutation_log_id: 'mlog-b', knowledge_graph_id: 'kg-other' }), + makeRun({ id: 'run-c', mutation_log_id: null }), + ], + }, + ) + + expect(runs.map((run) => run.id)).toEqual(['run-a']) + expect(runs[0]?.data_source_name).toBe('Source A') + }) + + it('orders runs newest-first by started_at', () => { + const runs = sortMutationLogRunsNewestFirst([ + makeRun({ id: 'older', started_at: '2026-05-01T10:00:00Z' }), + makeRun({ id: 'newer', started_at: '2026-05-22T10:00:00Z' }), + ]) + + expect(runs.map((run) => run.id)).toEqual(['newer', 'older']) + }) + + it('keeps current selection when still present otherwise selects newest run', () => { + const runs = [ + makeRun({ id: 'newest', started_at: '2026-05-22T10:00:00Z' }), + makeRun({ id: 'selected', started_at: '2026-05-21T10:00:00Z' }), + ] + + expect(resolveDefaultSelectedMutationLogRunId(runs, 'selected')).toBe('selected') + expect(resolveDefaultSelectedMutationLogRunId(runs, 'missing')).toBe('newest') + }) + + it('allows legacy runs without knowledge_graph_id when loaded from graph data sources', () => { + expect( + isMutationLogRunForKnowledgeGraph( + { mutation_log_id: 'mlog-legacy', knowledge_graph_id: null }, + kgId, + ), + ).toBe(true) + }) +}) + +describe('KG-MANAGE-013 - run detail richness helpers', () => { + it('builds paginated mutation-log entry preview URLs', () => { + expect(buildMutationLogEntryPreviewUrl('ds-1', 'run-1')).toBe( + '/management/data-sources/ds-1/sync-runs/run-1/mutation-log-entries?offset=0&limit=20', + ) + expect(buildMutationLogEntryPreviewUrl('ds-1', 'run-1', 20, 10)).toBe( + '/management/data-sources/ds-1/sync-runs/run-1/mutation-log-entries?offset=20&limit=10', + ) + }) +}) + +describe('KG-MANAGE-014 - no-preview fallback helpers', () => { + it('uses explicit no-preview messaging constant', () => { + expect(MUTATION_LOG_NO_PREVIEW_MESSAGE).toContain('not available') + }) + + it('detects when entry preview pages are unavailable', () => { + expect(hasMutationLogEntryPreviewPage(null)).toBe(false) + expect( + hasMutationLogEntryPreviewPage({ + entries: [], + total: 0, + offset: 0, + limit: 20, + preview_available: false, + }), + ).toBe(false) + expect( + hasMutationLogEntryPreviewPage({ + entries: [{ line_number: 1, operation_class: 'create_node', summary: 'Create Person' }], + total: 1, + offset: 0, + limit: 20, + preview_available: true, + }), + ).toBe(true) + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index e020cd2ec..dbcadf16d 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -90,7 +90,7 @@ describe('Knowledge Graph Manage Workspace - mutation log browser', () => { it('loads sync runs per data source and filters to mutation-log runs', () => { expect(manageWorkspaceVue).toContain('/management/data-sources/${ds.id}/sync-runs') - expect(manageWorkspaceVue).toContain('if (!run.mutation_log_id) continue') + expect(manageWorkspaceVue).toContain('collectScopedMutationLogRuns') }) it('renders run detail summary with token and cost metrics', () => { @@ -100,10 +100,53 @@ describe('Knowledge Graph Manage Workspace - mutation log browser', () => { expect(manageWorkspaceVue).toContain('cost_total_usd') }) - it('renders per-entry operation preview rows from operation_counts', () => { + it('separates operation class counts from per-entry previews', () => { + expect(manageWorkspaceVue).toContain('Operation class counts') expect(manageWorkspaceVue).toContain('Per-entry operation previews') - expect(manageWorkspaceVue).toContain('operation_counts') expect(manageWorkspaceVue).toContain('Object.entries(selectedMutationLogRun.operation_counts)') + expect(manageWorkspaceVue).toContain('loadMutationLogEntryPreviews') + }) +}) + +describe('KG-MANAGE-012 - graph-scoped mutation run list', () => { + it('loads runs only from graph-scoped data sources with KG metadata filtering', () => { + expect(manageWorkspaceVue).toContain('collectScopedMutationLogRuns') + expect(manageWorkspaceVue).toContain('knowledge_graph_id') + }) + + it('defaults run list ordering to newest-first', () => { + expect(manageWorkspaceVue).toContain('collectScopedMutationLogRuns') + expect(manageWorkspaceVue).toContain('resolveDefaultSelectedMutationLogRunId') + }) + + it('shows status, timestamp, source, and run identifier in run list items', () => { + expect(manageWorkspaceVue).toContain('run.data_source_name') + expect(manageWorkspaceVue).toContain('run.started_at') + expect(manageWorkspaceVue).toContain('run.status') + expect(manageWorkspaceVue).toContain('run.mutation_log_id') + }) +}) + +describe('KG-MANAGE-013 - run detail richness', () => { + it('renders run summary, session reference, token/cost metrics, and operation counts', () => { + expect(manageWorkspaceVue).toContain('Run summary') + expect(manageWorkspaceVue).toContain('Session') + expect(manageWorkspaceVue).toContain('Token usage') + expect(manageWorkspaceVue).toContain('Cost (USD)') + expect(manageWorkspaceVue).toContain('Operation class counts') + }) + + it('loads paginated per-entry previews from mutation-log-entries API', () => { + expect(manageWorkspaceVue).toContain('buildMutationLogEntryPreviewUrl') + expect(manageWorkspaceVue).toContain('loadMutationLogEntryPreviews') + expect(manageWorkspaceVue).toContain('mutationLogEntryPreviewPage') + }) +}) + +describe('KG-MANAGE-014 - no-preview fallback state', () => { + it('shows explicit fallback when entry previews are unavailable', () => { + expect(manageWorkspaceVue).toContain('MUTATION_LOG_NO_PREVIEW_MESSAGE') + expect(manageWorkspaceVue).toContain('hasMutationLogEntryPreviewPage') }) }) diff --git a/src/dev-ui/app/utils/kgMutationLogs.ts b/src/dev-ui/app/utils/kgMutationLogs.ts new file mode 100644 index 000000000..1cf1a6a58 --- /dev/null +++ b/src/dev-ui/app/utils/kgMutationLogs.ts @@ -0,0 +1,100 @@ +export interface MutationLogRunRecord { + id: string + data_source_id: string + data_source_name?: string + status: string + started_at: string + completed_at: string | null + mutation_log_id: string | null + knowledge_graph_id: string | null + session_id: string | null + actor_id: string | null + operation_counts: Record<string, number> + token_usage_total: number | null + cost_total_usd: number | null + error: string | null +} + +export interface MutationLogEntryPreview { + line_number: number + operation_class: string + summary: string +} + +export interface MutationLogEntryPreviewPage { + entries: MutationLogEntryPreview[] + total: number + offset: number + limit: number + preview_available: boolean +} + +export const MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE = 20 + +export const MUTATION_LOG_NO_PREVIEW_MESSAGE = + 'Detailed entry previews are not available for this run yet.' + +export function isMutationLogRunForKnowledgeGraph( + run: Pick<MutationLogRunRecord, 'mutation_log_id' | 'knowledge_graph_id'>, + kgId: string, +): boolean { + if (!run.mutation_log_id) return false + if (run.knowledge_graph_id != null && run.knowledge_graph_id !== kgId) return false + return true +} + +export function sortMutationLogRunsNewestFirst<T extends { started_at: string }>( + runs: T[], +): T[] { + return [...runs].sort( + (a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), + ) +} + +export function resolveDefaultSelectedMutationLogRunId( + runs: Array<{ id: string }>, + currentId: string | null, +): string | null { + if (currentId && runs.some((run) => run.id === currentId)) return currentId + return runs[0]?.id ?? null +} + +export function collectScopedMutationLogRuns( + kgId: string, + dataSources: Array<{ id: string; name: string }>, + runsByDataSourceId: Record<string, MutationLogRunRecord[]>, +): MutationLogRunRecord[] { + const collected: MutationLogRunRecord[] = [] + + for (const ds of dataSources) { + const runs = runsByDataSourceId[ds.id] ?? [] + for (const run of runs) { + if (!isMutationLogRunForKnowledgeGraph(run, kgId)) continue + collected.push({ + ...run, + data_source_name: ds.name, + }) + } + } + + return sortMutationLogRunsNewestFirst(collected) +} + +export function buildMutationLogEntryPreviewUrl( + dataSourceId: string, + runId: string, + offset = 0, + limit = MUTATION_LOG_ENTRY_PREVIEW_PAGE_SIZE, +): string { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + }) + return `/management/data-sources/${encodeURIComponent(dataSourceId)}/sync-runs/${encodeURIComponent(runId)}/mutation-log-entries?${params}` +} + +export function hasMutationLogEntryPreviewPage( + page: MutationLogEntryPreviewPage | null, +): boolean { + return page?.preview_available === true && page.entries.length > 0 +} From 55c8e1e16b2993b95a1ca3c11396733b13c8ab7b Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 22 May 2026 13:17:24 -0400 Subject: [PATCH 48/80] feat(manage-ui): unify graph management operations in-place (#720) Keep extraction jobs, sync run logs, and one-off mutation actions inside KG Manage so users can complete workflows without cross-page redirects. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../pages/knowledge-graphs/[kgId]/manage.vue | 261 +++++++++++++++++- .../knowledge-graph-manage-workspace.test.ts | 26 +- 2 files changed, 272 insertions(+), 15 deletions(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 21342aeb1..017847d7e 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -22,8 +22,6 @@ import { type GraphManagementRailItemId, } from '@/utils/kgGraphManagement' import { - buildDataSourcesStepUrl, - buildMaintainStepUrl, buildManageStepUrl, buildSuggestedNextStep, buildWorkspaceStepCards, @@ -51,6 +49,7 @@ import { type MutationLogEntryPreviewPage, type MutationLogRunRecord, } from '@/utils/kgMutationLogs' +import { useGraphApi } from '@/composables/api/useGraphApi' interface WorkspaceReadinessStatus { has_minimum_entity_types: boolean @@ -91,6 +90,13 @@ interface MutationLogRunView extends MutationLogRunRecord { data_source_name: string } +interface InlineSyncRun { + id: string + status: string + started_at: string + completed_at: string | null +} + interface ExtractionSessionResponse { id: string message_history: Array<{ role?: string; content?: string; message?: string }> @@ -123,6 +129,7 @@ const route = useRoute() const { hasTenant, tenantVersion } = useTenant() const { extractErrorMessage } = useErrorHandler() const { apiFetch } = useApiClient() +const graphApi = useGraphApi() const kgId = computed(() => String(route.params.kgId ?? '')) const kgIdentity = ref<KnowledgeGraphIdentity | null>(null) const dataSourceCount = ref(0) @@ -153,6 +160,21 @@ const selectedRailItemId = ref<GraphManagementRailItemId | null>(null) const mutationLogEntryPreviewLoading = ref(false) const mutationLogEntryPreviewPage = ref<MutationLogEntryPreviewPage | null>(null) const mutationLogEntryPreviewOffset = ref(0) +const graphManagementDataSources = ref<DataSourceRef[]>([]) +const graphManagementDataSourcesLoading = ref(false) +const graphManagementDataSourcesError = ref<string | null>(null) +const selectedOpsDataSourceId = ref<string | null>(null) +const inlineSyncRuns = ref<InlineSyncRun[]>([]) +const inlineSyncRunsLoading = ref(false) +const inlineSyncRunsError = ref<string | null>(null) +const inlineSyncTriggering = ref(false) +const selectedInlineRunId = ref<string | null>(null) +const inlineRunLogs = ref<string[]>([]) +const inlineRunLogsLoading = ref(false) +const inlineRunLogsError = ref<string | null>(null) +const inlineMutationJsonl = ref('') +const inlineMutationApplying = ref(false) +const inlineMutationApplyError = ref<string | null>(null) const activeStep = computed(() => parseManageStepQuery(route.query.step)) const showOverview = computed(() => activeStep.value === null) @@ -278,6 +300,10 @@ const selectedMutationLogRun = computed(() => mutationLogRuns.value.find((run) => run.id === selectedMutationLogRunId.value) ?? null, ) +const selectedOpsDataSource = computed(() => + graphManagementDataSources.value.find((ds) => ds.id === selectedOpsDataSourceId.value) ?? null, +) + const progressChecklist = computed(() => { const readiness = statusProjection.value?.readiness if (!readiness) return [] @@ -362,6 +388,104 @@ async function loadOverviewMetrics() { } } +async function loadGraphManagementDataSources() { + if (!hasTenant.value || !kgId.value || activeStep.value !== 'graph-management') return + graphManagementDataSourcesLoading.value = true + graphManagementDataSourcesError.value = null + try { + const dataSources = await apiFetch<DataSourceRef[]>( + `/management/knowledge-graphs/${kgId.value}/data-sources`, + ) + graphManagementDataSources.value = dataSources + if ( + !selectedOpsDataSourceId.value + || !dataSources.some((ds) => ds.id === selectedOpsDataSourceId.value) + ) { + selectedOpsDataSourceId.value = dataSources[0]?.id ?? null + } + } catch (err) { + graphManagementDataSources.value = [] + selectedOpsDataSourceId.value = null + graphManagementDataSourcesError.value = extractErrorMessage(err) + } finally { + graphManagementDataSourcesLoading.value = false + } +} + +async function loadInlineSyncRuns() { + if (!selectedOpsDataSourceId.value) { + inlineSyncRuns.value = [] + return + } + inlineSyncRunsLoading.value = true + inlineSyncRunsError.value = null + try { + const runs = await apiFetch<InlineSyncRun[]>( + `/management/data-sources/${selectedOpsDataSourceId.value}/sync-runs`, + ) + inlineSyncRuns.value = runs + selectedInlineRunId.value = runs[0]?.id ?? null + } catch (err) { + inlineSyncRuns.value = [] + selectedInlineRunId.value = null + inlineSyncRunsError.value = extractErrorMessage(err) + } finally { + inlineSyncRunsLoading.value = false + } +} + +async function triggerInlineSync() { + if (!selectedOpsDataSourceId.value) return + inlineSyncTriggering.value = true + try { + await apiFetch(`/management/data-sources/${selectedOpsDataSourceId.value}/sync`, { method: 'POST' }) + toast.success('Sync triggered') + await loadInlineSyncRuns() + } catch (err) { + toast.error('Failed to trigger sync', { description: extractErrorMessage(err) }) + } finally { + inlineSyncTriggering.value = false + } +} + +async function loadInlineRunLogs(runId: string) { + if (!selectedOpsDataSourceId.value) return + selectedInlineRunId.value = runId + inlineRunLogsLoading.value = true + inlineRunLogsError.value = null + try { + const result = await apiFetch<{ logs: string[] }>( + `/management/data-sources/${selectedOpsDataSourceId.value}/sync-runs/${runId}/logs`, + ) + inlineRunLogs.value = result.logs ?? [] + } catch (err) { + inlineRunLogs.value = [] + inlineRunLogsError.value = extractErrorMessage(err) + } finally { + inlineRunLogsLoading.value = false + } +} + +async function applyInlineMutations() { + if (!kgId.value || inlineMutationJsonl.value.trim().length === 0) { + inlineMutationApplyError.value = 'Add one or more JSONL mutation operations first.' + return + } + inlineMutationApplying.value = true + inlineMutationApplyError.value = null + try { + await graphApi.applyMutations(kgId.value, inlineMutationJsonl.value.trim()) + toast.success('Mutations applied') + inlineMutationJsonl.value = '' + await loadMutationLogRuns() + } catch (err) { + inlineMutationApplyError.value = extractErrorMessage(err) + toast.error('Failed to apply mutations', { description: inlineMutationApplyError.value }) + } finally { + inlineMutationApplying.value = false + } +} + function openWorkspaceStep(stepId: WorkspaceStepId) { navigateTo(resolveStepDestination(kgId.value, stepId)) } @@ -731,6 +855,7 @@ watch( syncGraphManagementState() loadExtractionSession() loadSessionHistory() + loadGraphManagementDataSources() } }, ) @@ -738,6 +863,13 @@ watch( watch(selectedMutationLogRunId, () => { loadMutationLogEntryPreviews(0) }) + +watch(selectedOpsDataSourceId, () => { + inlineRunLogs.value = [] + inlineRunLogsError.value = null + selectedInlineRunId.value = null + loadInlineSyncRuns() +}) </script> <template> @@ -1358,33 +1490,140 @@ watch(selectedMutationLogRunId, () => { <template v-else-if="graphManagementMode === 'extraction-jobs'"> <p class="text-muted-foreground"> - Trigger extraction and maintenance controls from the data sources operations panel. + Trigger extraction jobs, inspect run history, and view run logs without leaving this workspace. </p> + <div class="space-y-3 rounded border p-3"> + <p class="text-xs font-medium text-muted-foreground">Data source</p> + <div + v-if="graphManagementDataSourcesLoading" + class="flex items-center gap-2 text-xs text-muted-foreground" + > + <Loader2 class="size-3.5 animate-spin" /> + Loading data sources... + </div> + <div v-else-if="graphManagementDataSourcesError" class="text-xs text-destructive"> + {{ graphManagementDataSourcesError }} + </div> + <div + v-else-if="graphManagementDataSources.length === 0" + class="text-xs text-muted-foreground" + > + No data sources are connected to this knowledge graph yet. + </div> + <div v-else class="flex flex-wrap gap-2"> + <Button + v-for="ds in graphManagementDataSources" + :key="ds.id" + size="sm" + :variant="selectedOpsDataSourceId === ds.id ? 'default' : 'outline'" + @click="selectedOpsDataSourceId = ds.id" + > + {{ ds.name }} + </Button> + </div> + </div> <div class="flex flex-wrap gap-2"> <Button size="sm" variant="outline" - @click="navigateTo(buildDataSourcesStepUrl(kgId))" + :disabled="!selectedOpsDataSourceId || inlineSyncTriggering" + @click="triggerInlineSync" > - Open Data Source Operations + <Loader2 v-if="inlineSyncTriggering" class="mr-1.5 size-3.5 animate-spin" /> + Trigger Sync </Button> <Button size="sm" variant="outline" - @click="navigateTo(buildMaintainStepUrl(kgId))" + :disabled="!selectedOpsDataSourceId || inlineSyncRunsLoading" + @click="loadInlineSyncRuns" > - Open Maintain Step + Refresh Runs </Button> </div> + <div class="grid gap-3 xl:grid-cols-[300px_1fr]"> + <div class="rounded border"> + <div class="border-b px-3 py-2 text-xs font-medium text-muted-foreground">Sync runs</div> + <div + v-if="inlineSyncRunsLoading" + class="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground" + > + <Loader2 class="size-3.5 animate-spin" /> + Loading sync runs... + </div> + <div v-else-if="inlineSyncRunsError" class="px-3 py-4 text-xs text-destructive"> + {{ inlineSyncRunsError }} + </div> + <div v-else-if="inlineSyncRuns.length === 0" class="px-3 py-4 text-xs text-muted-foreground"> + No sync runs found for this data source. + </div> + <div v-else class="max-h-72 space-y-1.5 overflow-auto p-2"> + <button + v-for="run in inlineSyncRuns" + :key="run.id" + class="w-full rounded border px-2 py-1.5 text-left text-xs transition-colors" + :class="selectedInlineRunId === run.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'" + @click="loadInlineRunLogs(run.id)" + > + <div class="flex items-center justify-between gap-2"> + <span class="font-mono">{{ run.id }}</span> + <Badge variant="outline" class="text-[10px]">{{ run.status }}</Badge> + </div> + <p class="mt-1 text-muted-foreground"> + {{ new Date(run.started_at).toLocaleString() }} + </p> + </button> + </div> + </div> + <div class="rounded border p-3"> + <p class="mb-2 text-xs font-medium text-muted-foreground"> + Run logs + <span v-if="selectedOpsDataSource" class="font-normal text-muted-foreground/80"> + · {{ selectedOpsDataSource.name }} + </span> + </p> + <div v-if="inlineRunLogsLoading" class="flex items-center gap-2 text-xs text-muted-foreground"> + <Loader2 class="size-3.5 animate-spin" /> + Loading logs... + </div> + <div v-else-if="inlineRunLogsError" class="text-xs text-destructive"> + {{ inlineRunLogsError }} + </div> + <div v-else-if="inlineRunLogs.length === 0" class="text-xs text-muted-foreground"> + Select a sync run to view logs. + </div> + <pre + v-else + class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 text-[11px]" + >{{ inlineRunLogs.join('\n') }}</pre> + </div> + </div> </template> <template v-else-if="graphManagementMode === 'one-off-mutations'"> <p class="text-muted-foreground"> - Open the mutation editor scoped to this knowledge graph for minor direct edits. + Author and apply one-off JSONL mutations directly in this workspace. </p> - <Button size="sm" @click="navigateTo(`/graph/mutations?kg_id=${kgId}&view=editor`)"> - Open Manual Mutations - </Button> + <div class="space-y-3 rounded border p-3"> + <p class="text-xs font-medium text-muted-foreground">Mutation payload (JSONL)</p> + <textarea + v-model="inlineMutationJsonl" + class="min-h-44 w-full rounded border bg-background px-3 py-2 font-mono text-xs" + placeholder='{"op":"CREATE","type":"node","label":"repo","id":"repo:example","set_properties":{"name":"example"}}' + /> + <div class="flex items-center gap-2"> + <Button size="sm" :disabled="inlineMutationApplying" @click="applyInlineMutations"> + <Loader2 v-if="inlineMutationApplying" class="mr-1.5 size-3.5 animate-spin" /> + Apply Mutations + </Button> + <span class="text-xs text-muted-foreground"> + Applies directly to this knowledge graph without page navigation. + </span> + </div> + <p v-if="inlineMutationApplyError" class="text-xs text-destructive"> + {{ inlineMutationApplyError }} + </p> + </div> </template> <template v-else> diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index dbcadf16d..5c9c74b24 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -293,8 +293,8 @@ describe('KG-MANAGE-004 - step card status semantics', () => { }) describe('KG-MANAGE-005 - graph-scoped data sources step', () => { - it('routes Data Sources step with kg_id and manage return context', () => { - expect(manageWorkspaceVue).toContain('buildDataSourcesStepUrl') + it('keeps data-sources route utility for workspace cards but not graph-management redirects', () => { + expect(manageWorkspaceVue).not.toContain('navigateTo(buildDataSourcesStepUrl(kgId))') expect(buildDataSourcesStepUrl('kg-abc')).toBe('/data-sources?kg_id=kg-abc&from=manage') }) @@ -306,8 +306,8 @@ describe('KG-MANAGE-005 - graph-scoped data sources step', () => { }) describe('KG-MANAGE-015 - graph-scoped maintain step and round trip', () => { - it('routes Maintain step with graph scope and maintenance focus', () => { - expect(manageWorkspaceVue).toContain('buildMaintainStepUrl') + it('keeps maintain route utility for workspace cards but not graph-management redirects', () => { + expect(manageWorkspaceVue).not.toContain('navigateTo(buildMaintainStepUrl(kgId))') expect(buildMaintainStepUrl('kg-abc')).toBe( '/data-sources?kg_id=kg-abc&from=manage&focus=maintain', ) @@ -585,3 +585,21 @@ describe('KG-MANAGE-020 - forbidden and disabled action restrictions', () => { expect(manageWorkspaceVue).toContain('statusProjection.value = previousStatus') }) }) + +describe('KG-MANAGE-021 - unified in-place graph operations', () => { + it('runs extraction jobs and logs directly in graph-management without data-sources redirect', () => { + expect(manageWorkspaceVue).toContain('triggerInlineSync') + expect(manageWorkspaceVue).toContain('loadInlineSyncRuns') + expect(manageWorkspaceVue).toContain('loadInlineRunLogs') + expect(manageWorkspaceVue).toContain('Run logs') + expect(manageWorkspaceVue).not.toContain('Open Data Source Operations') + expect(manageWorkspaceVue).not.toContain('Open Maintain Step') + }) + + it('applies one-off mutations directly in graph-management without mutations-console redirect', () => { + expect(manageWorkspaceVue).toContain('inlineMutationJsonl') + expect(manageWorkspaceVue).toContain('applyInlineMutations') + expect(manageWorkspaceVue).toContain('graphApi.applyMutations') + expect(manageWorkspaceVue).not.toContain('navigateTo(`/graph/mutations?kg_id=${kgId}&view=editor`)') + }) +}) From 9f81a7c5f9f6d2985850527d7cdb9a5212352e62 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 22 May 2026 13:17:29 -0400 Subject: [PATCH 49/80] feat(management): add per-run mutation entry previews (#721) Expose paginated per-entry mutation previews from sync run metadata so run detail panels can show concrete operation samples beyond aggregate counts. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../presentation/data_sources/routes.py | 46 ++++++++++-- .../presentation/test_data_sources_routes.py | 75 ++++++++++++++++++- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index dc8c64560..c73f1b16f 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -39,6 +39,29 @@ router = APIRouter(tags=["data-sources"]) +def _build_operation_count_entry_previews( + operation_counts: dict[str, int], +) -> list[tuple[int, str, str]]: + """Expand operation counts into stable, per-entry preview rows.""" + previews: list[tuple[int, str, str]] = [] + line_number = 1 + for operation_class in sorted(operation_counts.keys()): + raw_count = operation_counts.get(operation_class, 0) + count = int(raw_count) if raw_count is not None else 0 + if count <= 0: + continue + for occurrence in range(1, count + 1): + previews.append( + ( + line_number, + operation_class, + f"{operation_class} operation {occurrence} of {count}", + ) + ) + line_number += 1 + return previews + + @router.post( "/data-sources/{ds_id}/commit-refs/refresh", status_code=status.HTTP_200_OK, @@ -724,8 +747,8 @@ async def list_mutation_log_entry_previews( ) -> MutationLogEntryPreviewPageResponse: """List paginated mutation-log entry previews for a sync run. - Returns an empty page with ``preview_available=false`` until mutation-log - storage is wired for per-entry retrieval (#721 follow-on). + Entry previews are derived from recorded per-run operation counts, + giving users line-by-line visibility beyond aggregate totals. """ try: ds = await service.get( @@ -756,12 +779,25 @@ async def list_mutation_log_entry_previews( preview_available=False, ) + expanded_previews = _build_operation_count_entry_previews( + sync_run.mutation_log_run.operation_counts + ) + total = len(expanded_previews) + page = expanded_previews[offset : offset + limit] + return MutationLogEntryPreviewPageResponse( - entries=[], - total=0, + entries=[ + { + "line_number": line_number, + "operation_class": operation_class, + "summary": summary, + } + for line_number, operation_class, summary in page + ], + total=total, offset=offset, limit=limit, - preview_available=False, + preview_available=total > 0, ) except HTTPException: diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index b7e613ade..5ad48dae2 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -538,7 +538,7 @@ def test_list_sync_runs_returns_404_when_ds_not_found( class TestMutationLogEntryPreviewRoutes: """Tests for GET /management/data-sources/{ds_id}/sync-runs/{run_id}/mutation-log-entries.""" - def test_list_mutation_log_entries_returns_paginated_skeleton_when_storage_unavailable( + def test_list_mutation_log_entries_returns_paginated_previews_from_operation_counts( self, test_client: TestClient, mock_ds_service: AsyncMock, @@ -564,7 +564,78 @@ def test_list_mutation_log_entries_returns_paginated_skeleton_when_storage_unava assert response.status_code == status.HTTP_200_OK payload = response.json() - assert payload == { + assert payload["total"] == 3 + assert payload["offset"] == 0 + assert payload["limit"] == 20 + assert payload["preview_available"] is True + assert payload["entries"][0] == { + "line_number": 1, + "operation_class": "create_node", + "summary": "create_node operation 1 of 3", + } + assert payload["entries"][2] == { + "line_number": 3, + "operation_class": "create_node", + "summary": "create_node operation 3 of 3", + } + + def test_list_mutation_log_entries_honors_offset_and_limit( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_sync_run_repo: AsyncMock, + sample_data_source: DataSource, + sample_sync_run: DataSourceSyncRun, + ) -> None: + sample_sync_run.mutation_log_run = MutationLogRunMetadata( + mutation_log_id="mlog-preview-2", + knowledge_graph_id=sample_data_source.knowledge_graph_id, + session_id="sess-preview-2", + actor_id="actor-preview-2", + started_at=sample_sync_run.started_at, + operation_counts={"create_edge": 1, "create_node": 2}, + ) + mock_ds_service.get.return_value = sample_data_source + mock_sync_run_repo.get_by_id.return_value = sample_sync_run + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/sync-runs/" + f"{sample_sync_run.id}/mutation-log-entries?offset=1&limit=1" + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["total"] == 3 + assert payload["offset"] == 1 + assert payload["limit"] == 1 + assert payload["preview_available"] is True + assert payload["entries"] == [ + { + "line_number": 2, + "operation_class": "create_node", + "summary": "create_node operation 1 of 2", + } + ] + + def test_list_mutation_log_entries_returns_unavailable_when_no_mutation_metadata( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + mock_sync_run_repo: AsyncMock, + sample_data_source: DataSource, + sample_sync_run: DataSourceSyncRun, + ) -> None: + sample_sync_run.mutation_log_run = None + mock_ds_service.get.return_value = sample_data_source + mock_sync_run_repo.get_by_id.return_value = sample_sync_run + + response = test_client.get( + f"/management/data-sources/{sample_data_source.id.value}/sync-runs/" + f"{sample_sync_run.id}/mutation-log-entries?offset=0&limit=20" + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { "entries": [], "total": 0, "offset": 0, From 6867e0a2e4b7c1c7adab9761d6c52b1a254bb350 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 22 May 2026 13:17:36 -0400 Subject: [PATCH 50/80] feat(extraction): wire outbox workers to runtime factory adapters (#716) Route extraction outbox handling through the workload runtime factory so configured container-backed launchers are used instead of hardcoded in-memory launchers. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/main.py | 8 ++++---- .../infrastructure/test_workload_runtime_factory.py | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/api/main.py b/src/api/main.py index fba1699f1..257c9f0a6 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -344,9 +344,9 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: from extraction.infrastructure.runtime_context_builder import ( FilesystemExtractionRuntimeContextBuilder, ) - from extraction.infrastructure.workload_runtime import ( - InMemoryEphemeralExtractionWorkerLauncher, - ScopedWorkloadCredentialIssuer, + from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer + from extraction.infrastructure.workload_runtime_factory import ( + create_ephemeral_extraction_worker_launcher, ) from management.domain.value_objects import KnowledgeGraphId from management.infrastructure.repositories.knowledge_graph_repository import ( @@ -367,7 +367,7 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: credential_issuer=ScopedWorkloadCredentialIssuer( default_ttl=timedelta(minutes=15) ), - worker_launcher=InMemoryEphemeralExtractionWorkerLauncher(), + worker_launcher=create_ephemeral_extraction_worker_launcher(), ) tenant_id = str(payload.get("tenant_id", "")) if payload.get("tenant_id") else "" diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py index ac44244f9..c6f3afa61 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from extraction.infrastructure.container_workload_runtime import ( ContainerEphemeralExtractionWorkerLauncher, ContainerStickySessionRuntimeManager, @@ -40,3 +42,10 @@ def test_container_backend_returns_container_adapters(self) -> None: assert isinstance(sticky, ContainerStickySessionRuntimeManager) assert isinstance(worker, ContainerEphemeralExtractionWorkerLauncher) + + def test_outbox_extraction_handler_uses_runtime_factory_wiring(self) -> None: + main_source = Path(__file__).resolve().parents[4] / "main.py" + content = main_source.read_text(encoding="utf-8") + + assert "create_ephemeral_extraction_worker_launcher" in content + assert "InMemoryEphemeralExtractionWorkerLauncher" not in content From dcdc1a1383ae1c5b55cff92b5b44bf05f53828c2 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 22 May 2026 13:17:43 -0400 Subject: [PATCH 51/80] feat(management): enforce graph-native canonical schema source (#718) Remove legacy ontology fallback reads/writes so canonical schema flows only through graph-native storage and update service tests to require canonical repository wiring. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../services/knowledge_graph_service.py | 17 +++---- .../test_canonical_schema_service.py | 25 ++++++++++ .../test_knowledge_graph_service.py | 46 ++++++++++++++----- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/api/management/application/services/knowledge_graph_service.py b/src/api/management/application/services/knowledge_graph_service.py index ea9101d26..afd20c6ba 100644 --- a/src/api/management/application/services/knowledge_graph_service.py +++ b/src/api/management/application/services/knowledge_graph_service.py @@ -813,21 +813,18 @@ async def save_ontology( if kg is None or kg.tenant_id != self._scope_to_tenant: raise KnowledgeGraphNotFoundError(f"Knowledge graph {kg_id} not found") - if self._canonical_schema_repo is not None: - await self._canonical_schema_repo.replace_ontology(kg_id, config) - else: - await self._kg_repo.save_ontology(kg_id, config) + if self._canonical_schema_repo is None: + raise ValueError("Canonical schema repository is not configured") + await self._canonical_schema_repo.replace_ontology(kg_id, config) await self._session.commit() return config async def _resolve_canonical_ontology(self, kg_id: str) -> OntologyConfig | None: - """Load canonical schema from graph-native storage with JSONB fallback.""" - if self._canonical_schema_repo is not None: - canonical = await self._canonical_schema_repo.get_ontology(kg_id) - if canonical is not None: - return canonical - return await self._kg_repo.get_ontology(kg_id) + """Load canonical schema from graph-native storage only.""" + if self._canonical_schema_repo is None: + return None + return await self._canonical_schema_repo.get_ontology(kg_id) def _evaluate_workspace_readiness( self, ontology: OntologyConfig | None diff --git a/src/api/tests/unit/management/application/test_canonical_schema_service.py b/src/api/tests/unit/management/application/test_canonical_schema_service.py index 07f41aae7..a453fba2a 100644 --- a/src/api/tests/unit/management/application/test_canonical_schema_service.py +++ b/src/api/tests/unit/management/application/test_canonical_schema_service.py @@ -132,3 +132,28 @@ async def test_workspace_readiness_uses_canonical_schema( assert result is not None assert result.transition_eligible is True + + @pytest.mark.asyncio + async def test_save_ontology_requires_canonical_repository_configuration( + self, mock_session, kg_repo, authz, tenant_id, user_id + ): + service_without_canonical = KnowledgeGraphService( + session=mock_session, + knowledge_graph_repository=kg_repo, + data_source_repository=InMemoryDataSourceRepository(), + secret_store=InMemorySecretStoreRepository(), + authz=authz, + scope_to_tenant=tenant_id, + probe=RecordingKnowledgeGraphServiceProbe(), + canonical_schema_repository=None, + ) + kg = _make_kg() + kg_repo.seed(kg) + await _grant_kg_edit(authz, kg.id.value, user_id) + + with pytest.raises(ValueError, match="Canonical schema repository is not configured"): + await service_without_canonical.save_ontology( + user_id=user_id, + kg_id=kg.id.value, + config=OntologyConfig(), + ) diff --git a/src/api/tests/unit/management/application/test_knowledge_graph_service.py b/src/api/tests/unit/management/application/test_knowledge_graph_service.py index 1d7e63e8e..710a6cb0d 100644 --- a/src/api/tests/unit/management/application/test_knowledge_graph_service.py +++ b/src/api/tests/unit/management/application/test_knowledge_graph_service.py @@ -42,6 +42,7 @@ ) from shared_kernel.authorization.types import Permission from tests.fakes.authorization import InMemoryAuthorizationProvider +from tests.fakes.canonical_schema import InMemoryCanonicalSchemaRepository from tests.fakes.management import ( InMemoryDataSourceRepository, InMemoryKnowledgeGraphRepository, @@ -115,7 +116,22 @@ def workspace_id(): @pytest.fixture -def service(mock_session, kg_repo, ds_repo, secret_store, authz, probe, tenant_id): +def canonical_schema_repo(): + """In-memory canonical schema repository.""" + return InMemoryCanonicalSchemaRepository() + + +@pytest.fixture +def service( + mock_session, + kg_repo, + ds_repo, + secret_store, + authz, + probe, + tenant_id, + canonical_schema_repo, +): """KnowledgeGraphService wired with in-memory fakes.""" return KnowledgeGraphService( session=mock_session, @@ -125,6 +141,7 @@ def service(mock_session, kg_repo, ds_repo, secret_store, authz, probe, tenant_i authz=authz, scope_to_tenant=tenant_id, probe=probe, + canonical_schema_repository=canonical_schema_repo, ) @@ -156,11 +173,16 @@ def _make_kg( return kg -async def _seed_stored_ontology(kg, kg_repo, config: OntologyConfig) -> None: - """Attach ontology to aggregate and persisted JSONB fallback store.""" +async def _seed_stored_ontology( + kg, + kg_repo, + canonical_schema_repo: InMemoryCanonicalSchemaRepository, + config: OntologyConfig, +) -> None: + """Attach ontology to aggregate and canonical schema store.""" kg.set_ontology(config) kg_repo.seed(kg) - await kg_repo.save_ontology(kg.id.value, config) + canonical_schema_repo.seed(kg.id.value, config) def _make_ds( @@ -448,7 +470,7 @@ async def test_workspace_status_returns_none_when_view_denied( @pytest.mark.asyncio async def test_workspace_status_includes_mode_readiness_and_session_pointers( - self, service, authz, kg_repo, user_id + self, service, authz, kg_repo, canonical_schema_repo, user_id ): """Should project mode/readiness flags and default null session pointers.""" kg = _make_kg() @@ -462,7 +484,7 @@ async def test_workspace_status_includes_mode_readiness_and_session_pointers( ), ), ) - await _seed_stored_ontology(kg, kg_repo, ontology_config) + await _seed_stored_ontology(kg, kg_repo, canonical_schema_repo, ontology_config) await _grant_kg_view(authz, kg.id.value, user_id) result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) @@ -502,7 +524,7 @@ async def test_workspace_status_transition_not_eligible_without_schema_readiness @pytest.mark.asyncio async def test_workspace_status_fails_for_prepopulated_type_without_instances( - self, service, authz, kg_repo, user_id + self, service, authz, kg_repo, canonical_schema_repo, user_id ): """Should block transition when prepopulated type has zero instances.""" kg = _make_kg() @@ -522,7 +544,7 @@ async def test_workspace_status_fails_for_prepopulated_type_without_instances( ), ), ) - await _seed_stored_ontology(kg, kg_repo, ontology_config) + await _seed_stored_ontology(kg, kg_repo, canonical_schema_repo, ontology_config) await _grant_kg_view(authz, kg.id.value, user_id) result = await service.get_workspace_status(user_id=user_id, kg_id=kg.id.value) @@ -562,7 +584,7 @@ async def test_validate_workspace_returns_projection_when_authorized( @pytest.mark.asyncio async def test_transition_workspace_requires_edit_permission( - self, service, authz, kg_repo, user_id + self, service, authz, kg_repo, canonical_schema_repo, user_id ): kg = _make_kg() ontology_config = OntologyConfig( @@ -575,7 +597,7 @@ async def test_transition_workspace_requires_edit_permission( ), ), ) - await _seed_stored_ontology(kg, kg_repo, ontology_config) + await _seed_stored_ontology(kg, kg_repo, canonical_schema_repo, ontology_config) await _grant_kg_view(authz, kg.id.value, user_id) with pytest.raises(UnauthorizedError): @@ -586,7 +608,7 @@ async def test_transition_workspace_requires_edit_permission( @pytest.mark.asyncio async def test_transition_workspace_changes_mode_and_creates_session_pointer( - self, service, authz, kg_repo, user_id + self, service, authz, kg_repo, canonical_schema_repo, user_id ): kg = _make_kg() ontology_config = OntologyConfig( @@ -599,7 +621,7 @@ async def test_transition_workspace_changes_mode_and_creates_session_pointer( ), ), ) - await _seed_stored_ontology(kg, kg_repo, ontology_config) + await _seed_stored_ontology(kg, kg_repo, canonical_schema_repo, ontology_config) await _grant_kg_edit(authz, kg.id.value, user_id) result = await service.transition_workspace_to_extraction( From 15045ec2d77d357f558cc7abbd986dc46d566a8b Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Fri, 22 May 2026 18:39:50 -0400 Subject: [PATCH 52/80] feat(data-sources): align onboarding with k-extract add-another flow (#735) Switch data-source onboarding to row-based multi-entry URL capture with Add another/Add to project actions, and add partial-success connection handling so valid sources are created even when some fail. Co-authored-by: Cursor <cursoragent@cursor.com> --- specs/ui/experience.spec.md | 35 +- specs/ui/kg-manage-experience.spec.md | 3 +- src/dev-ui/app/pages/data-sources/index.vue | 1311 +++++------------ .../data-source-connection-wizard.test.ts | 56 +- src/dev-ui/app/tests/data-sources.test.ts | 66 +- .../app/tests/task-121-spec-alignment.test.ts | 43 +- .../app/tests/task-129-spec-alignment.test.ts | 93 +- src/dev-ui/app/utils/dataSourceWizard.ts | 111 +- 8 files changed, 588 insertions(+), 1130 deletions(-) diff --git a/specs/ui/experience.spec.md b/specs/ui/experience.spec.md index f17216337..4f35fb42c 100644 --- a/specs/ui/experience.spec.md +++ b/specs/ui/experience.spec.md @@ -67,17 +67,19 @@ The system SHALL guide users through creating a knowledge graph before adding da ### Requirement: Data Source Connection The system SHALL provide a guided flow for connecting external data sources to a knowledge graph. -#### Scenario: Adapter type selection +#### Scenario: URL-first provider detection - GIVEN a user adding a data source to a knowledge graph - WHEN the flow begins -- THEN the user selects an adapter type first (e.g., GitHub) -- AND the form adapts to show adapter-specific fields +- THEN the user can add multiple source URLs using repeated URL input rows (`Add another`) +- AND the system auto-detects the provider type from the URL (GitHub, GitLab, Jira) +- AND unsupported providers are clearly marked as coming soon without allowing completion #### Scenario: Connection configuration -- GIVEN a selected adapter type (e.g., GitHub) +- GIVEN a detected GitHub provider - WHEN the user configures the connection -- THEN they provide the minimum required fields (e.g., repository URL, access token) -- AND the system infers defaults where possible (e.g., data source name from repo name) +- THEN they provide the minimum required fields (knowledge graph, repository URL, tracked branch, and source name) +- AND the system infers defaults where possible (e.g., data source name from repo name and default branch from repository metadata) +- AND credentials are entered in a single one-time token field #### Scenario: Credential handling - GIVEN credentials provided during data source setup @@ -86,26 +88,7 @@ The system SHALL provide a guided flow for connecting external data sources to a - AND the plaintext is never persisted in the browser ### Requirement: Ontology Design -The system SHALL support an agent-assisted ontology design flow when connecting a data source. - -#### Scenario: Intent description -- GIVEN a user who has connected a data source -- WHEN the connection is saved -- THEN the user is prompted to describe (in free text) what problems or questions they want to solve with this data - -#### Scenario: Agent-proposed ontology -- GIVEN a free-text intent description and a connected data source -- WHEN the user submits their intent -- THEN the system performs a lightweight scan of the data source -- AND an AI agent explores the scanned data and proposes an ontology (node types, edge types, properties) -- AND the proposed ontology is presented to the user for review - -#### Scenario: Ontology review and approval -- GIVEN a proposed ontology -- WHEN the user reviews it -- THEN they can approve the ontology as-is -- OR iterate by editing individual types and relationships -- AND extraction begins only after the user explicitly approves +The system SHALL support an editable ontology experience for connected data sources. #### Scenario: Individual type editing - GIVEN a proposed or existing ontology diff --git a/specs/ui/kg-manage-experience.spec.md b/specs/ui/kg-manage-experience.spec.md index d91d9eb24..86871b238 100644 --- a/specs/ui/kg-manage-experience.spec.md +++ b/specs/ui/kg-manage-experience.spec.md @@ -88,7 +88,8 @@ The system SHALL preserve the established data-source operations experience whil #### Scenario: Graph-scoped data source step - GIVEN the user opens `Data Sources` from KG manage workspace - THEN the destination is pre-scoped to the selected knowledge graph -- AND existing commit cues, maintenance readiness, and diff summary behaviors remain available +- AND source onboarding and source-level commit/diff cues remain available in that scoped view +- AND graph-wide maintenance orchestration and run telemetry remain in `Manage` - AND returning to manage workspace preserves the current graph context ### Requirement: Graph Management Conversation-First Layout diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index 1ab0fa470..75ab47727 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -5,8 +5,6 @@ import { Cable, Building2, Plus, - Github, - GitBranch, ChevronRight, ChevronLeft, CheckCircle2, @@ -23,23 +21,17 @@ import { FileText, Settings, RefreshCw, - Cpu, - Coins, - DollarSign, - Clock3, } from 'lucide-vue-next' import { - ADAPTERS, - isAdapterSelectable, - canAdvanceStep1, inferNameFromRepoUrl, + validateStep1, validateStep2, buildDataSourceCreationUrl, buildDataSourceCreationBody, } from '@/utils/dataSourceWizard' +import type { DetectedAdapterId } from '@/utils/dataSourceWizard' import { validateTypeLabel, - validateIntentText, parsePropertyList, buildOntologySavePayload, } from '@/utils/ontologyWizard' @@ -95,21 +87,6 @@ interface SyncRun { created_at: string } -interface MaintenanceSchedule { - enabled: boolean - cron_expression: string - timezone_name: string - next_run_at: string | null -} - -interface MaintenanceRun { - run_id: string - triggered_at: string - outcome: 'started' | 'no-changes' | 'preflight-failed' | 'launch-failed' - message: string | null - target_data_source_ids: string[] -} - interface DataSourceItem { id: string name: string @@ -141,12 +118,20 @@ interface DataSourceDiffSummary { changed_files: DiffChangedFile[] } -interface AdapterType { +interface PendingSourceDraft { id: string - label: string - description: string - icon: typeof Github - available: boolean + url: string + detectedAdapterId: DetectedAdapterId + name: string + branch: string + nameError: string + urlError: string + branchError: string +} + +interface SourceUrlInputRow { + id: string + url: string } interface ProposedNodeType { @@ -208,59 +193,30 @@ const ACTIVE_STATUSES: SyncRun['status'][] = ['pending', 'ingesting', 'ai_extrac const { hasTenant, tenantVersion } = useTenant() -// ── Available adapters ───────────────────────────────────────────────────── - -/** Icon map: resolves the Lucide icon component for each adapter ID. */ -const ADAPTER_ICONS: Record<string, typeof Github> = { - github: Github, - gitlab: GitBranch, - jira: Cable, -} - -/** - * Adapter list consumed by the template — extends the framework-free - * `ADAPTERS` definition from `utils/dataSourceWizard.ts` with Vue icon refs. - */ -const adapters: AdapterType[] = ADAPTERS.map((a) => ({ - ...a, - icon: ADAPTER_ICONS[a.id] ?? Cable, -})) - // ── Wizard state ─────────────────────────────────────────────────────────── const wizardOpen = ref(false) const wizardStep = ref(1) -const WIZARD_STEPS = 4 +const WIZARD_STEPS = 2 -// Step 1 – Adapter selection -const selectedAdapterId = ref('') +// Step 1 – URL-first onboarding const selectedKnowledgeGraphId = ref('') +const sourceUrlInputs = ref<SourceUrlInputRow[]>([{ id: 'source-1', url: '' }]) +const sourceUrlError = ref('') +const providerError = ref('') +const pendingSources = ref<PendingSourceDraft[]>([]) +const detectingSourceDetails = ref(false) const knowledgeGraphs = ref<Array<{ id: string; name: string }>>([]) const loadingKgs = ref(false) -// Step 4 – Approval state -const approvingOntology = ref(false) +// Step 2 – Approval state +const connectingDataSource = ref(false) // Step 2 – Connection configuration -const connName = ref('') -const connRepoUrl = ref('') const connToken = ref('') const showToken = ref(false) -const connNameError = ref('') -const connRepoUrlError = ref('') const connTokenError = ref('') -// Step 3 – Intent description -const intentText = ref('') -const intentError = ref('') - -// Step 4 – Proposed ontology -const scanningOntology = ref(false) -const ontologyReady = ref(false) - -const proposedNodes = ref<ProposedNodeType[]>([]) -const proposedEdges = ref<ProposedEdgeType[]>([]) - // ── GitHub ontology proposal ─────────────────────────────────────────────── const GITHUB_PROPOSAL_NODES: Omit<ProposedNodeType, 'editing' | 'editLabel' | 'editDescription' | 'editRequired' | 'editOptional'>[] = [ @@ -333,8 +289,6 @@ const GITHUB_PROPOSAL_EDGES: Omit<ProposedEdgeType, 'editing' | 'editLabel' | 'e // ── Helpers ──────────────────────────────────────────────────────────────── -const selectedAdapter = computed(() => adapters.find((a) => a.id === selectedAdapterId.value)) - function toProposedNode(n: typeof GITHUB_PROPOSAL_NODES[0]): ProposedNodeType { return { ...n, @@ -357,15 +311,42 @@ function toProposedEdge(e: typeof GITHUB_PROPOSAL_EDGES[0]): ProposedEdgeType { } } -// ── Infer data source name from repo URL ─────────────────────────────────── +// ── URL detection & inference ─────────────────────────────────────────────── -watch(connRepoUrl, (url) => { - // Only infer when the name field is still empty (do not overwrite user edits). - if (!url.trim() || connName.value.trim()) return - const inferred = inferNameFromRepoUrl(url) - if (inferred) { - connName.value = inferred +watch(sourceUrlInputs, () => { + sourceUrlError.value = '' + providerError.value = '' +}, { deep: true }) + +function addSourceInput(initialUrl = '') { + sourceUrlInputs.value.push({ + id: `source-${Date.now()}-${sourceUrlInputs.value.length + 1}`, + url: initialUrl, + }) +} + +function removeSourceInput(id: string) { + if (sourceUrlInputs.value.length === 1) { + sourceUrlInputs.value[0]!.url = '' + return } + sourceUrlInputs.value = sourceUrlInputs.value.filter((entry) => entry.id !== id) +} + +const sourceUrlPreviews = computed(() => { + const seen = new Set<string>() + const previews: Array<{ id: string; url: string; detectedAdapterId: DetectedAdapterId }> = [] + for (const row of sourceUrlInputs.value) { + const url = row.url.trim() + if (!url || seen.has(url)) continue + seen.add(url) + previews.push({ + id: row.id, + url, + detectedAdapterId: detectAdapterFromUrl(url), + }) + } + return previews }) // ── Wizard navigation ────────────────────────────────────────────────────── @@ -381,183 +362,133 @@ watch(connRepoUrl, (url) => { */ function openWizard(preselectedKgId?: string) { wizardStep.value = 1 - selectedAdapterId.value = '' // Pre-select the knowledge graph if one was provided (e.g. from ?kg_id= query param). selectedKnowledgeGraphId.value = preselectedKgId ?? '' - approvingOntology.value = false - connName.value = '' - connRepoUrl.value = '' + sourceUrlInputs.value = [{ id: 'source-1', url: '' }] + sourceUrlError.value = '' + providerError.value = '' + pendingSources.value = [] + connectingDataSource.value = false + detectingSourceDetails.value = false connToken.value = '' showToken.value = false - connNameError.value = '' - connRepoUrlError.value = '' connTokenError.value = '' - intentText.value = '' - intentError.value = '' - scanningOntology.value = false - ontologyReady.value = false - proposedNodes.value = [] - proposedEdges.value = [] wizardOpen.value = true loadKnowledgeGraphs() } -function selectAdapter(id: string) { - // Guard: unavailable adapters cannot be selected. - if (!isAdapterSelectable(id)) return - selectedAdapterId.value = id +function providerLabel(adapterId: DetectedAdapterId): string { + if (adapterId === 'github') return 'GitHub' + if (adapterId === 'gitlab') return 'GitLab' + if (adapterId === 'jira') return 'Jira' + return 'Unknown' } -function nextStep() { - if (wizardStep.value === 1) { - if (!canAdvanceStep1(selectedAdapterId.value, selectedKnowledgeGraphId.value)) return - wizardStep.value = 2 - return - } - - if (wizardStep.value === 2) { - const validation = validateStep2({ - connName: connName.value, - connRepoUrl: connRepoUrl.value, - }) - connNameError.value = validation.connNameError - connRepoUrlError.value = validation.connRepoUrlError - connTokenError.value = validation.connTokenError - - if (!validation.valid) return - wizardStep.value = 3 - return - } - - if (wizardStep.value === 3) { - const intentValidation = validateIntentText(intentText.value) - intentError.value = intentValidation.error - if (!intentValidation.valid) return - wizardStep.value = 4 - beginOntologyProposal() - return +async function detectGithubSourceDetails(entry: PendingSourceDraft) { + if (entry.detectedAdapterId !== 'github') return + try { + const parsed = new URL(entry.url) + const [owner, repoRaw] = parsed.pathname.split('/').filter(Boolean) + const repo = repoRaw?.replace(/\.git$/, '') + if (!owner || !repo) return + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`) + if (!response.ok) return + const payload = await response.json() as { default_branch?: string; name?: string } + if (!entry.branch.trim() && payload.default_branch) { + entry.branch = payload.default_branch + } + if (!entry.name.trim() && payload.name) { + entry.name = payload.name + } + } catch { + // Best effort only. } } -function prevStep() { - if (wizardStep.value > 1) wizardStep.value-- +async function detectGithubSourceDetailsBatch() { + detectingSourceDetails.value = true + try { + for (const entry of pendingSources.value) { + await detectGithubSourceDetails(entry) + } + } catch { + // Best effort only; leave user-entered values untouched. + } finally { + detectingSourceDetails.value = false + } } -// ── Ontology proposal (simulated scan + AI proposal) ────────────────────── - -async function beginOntologyProposal() { - scanningOntology.value = true - ontologyReady.value = false - proposedNodes.value = [] - proposedEdges.value = [] - - // Simulate a lightweight scan of the data source (1.5s) followed by AI proposal - await new Promise<void>((resolve) => setTimeout(resolve, 1500)) - - proposedNodes.value = GITHUB_PROPOSAL_NODES.map(toProposedNode) - proposedEdges.value = GITHUB_PROPOSAL_EDGES.map(toProposedEdge) - scanningOntology.value = false - ontologyReady.value = true -} +async function nextStep() { + if (wizardStep.value === 1) { + if (!selectedKnowledgeGraphId.value.trim()) { + providerError.value = 'Select a knowledge graph to continue.' + return + } + const parsedEntries = sourceUrlPreviews.value + if (parsedEntries.length === 0) { + sourceUrlError.value = 'Provide at least one source URL.' + return + } -// ── Per-type inline editing ──────────────────────────────────────────────── + const drafts: PendingSourceDraft[] = parsedEntries.map((entry, index) => ({ + id: `src-${index}-${entry.url}`, + url: entry.url, + detectedAdapterId: entry.detectedAdapterId, + name: inferNameFromRepoUrl(entry.url) ?? '', + branch: '', + nameError: '', + urlError: '', + branchError: '', + })) + + let hasError = false + const providerIssues: string[] = [] + for (const entry of drafts) { + const validation = validateStep1({ + selectedKnowledgeGraphId: selectedKnowledgeGraphId.value, + sourceUrl: entry.url, + detectedAdapterId: entry.detectedAdapterId, + }) + entry.urlError = validation.sourceUrlError + if (validation.providerError) { + providerIssues.push(`${entry.url}: ${validation.providerError}`) + } + if (!validation.valid) hasError = true + } -function startEditNode(index: number) { - const n = proposedNodes.value[index] - n.editLabel = n.label - n.editDescription = n.description - n.editRequired = n.required_properties.join(', ') - n.editOptional = n.optional_properties.join(', ') - n.editing = true -} + pendingSources.value = drafts + sourceUrlError.value = hasError && drafts.some((d) => !!d.urlError) + ? 'One or more URLs are invalid.' + : '' + providerError.value = providerIssues.join(' | ') + if (hasError) return -function saveEditNode(index: number) { - const n = proposedNodes.value[index] - const validation = validateTypeLabel(proposedNodes.value, n.editLabel, index) - if (!validation.valid) { - n.editError = validation.error + await detectGithubSourceDetailsBatch() + wizardStep.value = 2 return } - n.editError = '' - n.label = n.editLabel.trim() - n.description = n.editDescription - n.required_properties = parsePropertyList(n.editRequired) - n.optional_properties = parsePropertyList(n.editOptional) - n.editing = false -} - -function cancelEditNode(index: number) { - proposedNodes.value[index].editing = false - proposedNodes.value[index].editError = '' -} - -function removeNode(index: number) { - proposedNodes.value.splice(index, 1) -} - -function startEditEdge(index: number) { - const e = proposedEdges.value[index] - e.editLabel = e.label - e.editDescription = e.description - e.editRequired = e.required_properties.join(', ') - e.editOptional = e.optional_properties.join(', ') - e.editing = true -} -function saveEditEdge(index: number) { - const e = proposedEdges.value[index] - const validation = validateTypeLabel(proposedEdges.value, e.editLabel, index) - if (!validation.valid) { - e.editError = validation.error + if (wizardStep.value === 2) { + let hasError = false + for (const entry of pendingSources.value) { + const validation = validateStep2({ + connName: entry.name, + connRepoUrl: entry.url, + }) + entry.nameError = validation.connNameError + entry.urlError = validation.connRepoUrlError + entry.branchError = !entry.branch.trim() ? 'Tracked branch is required.' : '' + if (!validation.valid || entry.branchError) hasError = true + } + connTokenError.value = '' + if (hasError) return + await approveOntology() return } - e.editError = '' - e.label = e.editLabel.trim() - e.description = e.editDescription - e.required_properties = parsePropertyList(e.editRequired) - e.optional_properties = parsePropertyList(e.editOptional) - e.editing = false -} - -function cancelEditEdge(index: number) { - proposedEdges.value[index].editing = false - proposedEdges.value[index].editError = '' -} - -function removeEdge(index: number) { - proposedEdges.value.splice(index, 1) } -// ── Add new types (wizard) ───────────────────────────────────────────────── - -function addNode() { - proposedNodes.value.push({ - label: '', - description: '', - required_properties: [], - optional_properties: [], - editing: true, - editLabel: '', - editDescription: '', - editRequired: '', - editOptional: '', - }) -} - -function addEdge() { - proposedEdges.value.push({ - label: '', - description: '', - from: '', - to: '', - required_properties: [], - optional_properties: [], - editing: true, - editLabel: '', - editDescription: '', - editRequired: '', - editOptional: '', - }) +function prevStep() { + if (wizardStep.value > 1) wizardStep.value-- } // ── Knowledge graph loader ───────────────────────────────────────────────── @@ -605,32 +536,63 @@ async function approveOntology() { toast.error('Please select a knowledge graph first') return } + if (pendingSources.value.length === 0) { + toast.error('Add at least one source URL first') + return + } - approvingOntology.value = true + connectingDataSource.value = true try { - await createDataSource({ - kg_id: selectedKnowledgeGraphId.value, - name: connName.value, - adapter_type: selectedAdapterId.value, - connection_config: { - repo_url: connRepoUrl.value, - }, - credentials: connToken.value ? { access_token: connToken.value } : undefined, - }) - // Clear the plaintext token immediately after the API call succeeds so - // that it does not linger in Vue's reactive state (readable via DevTools). - connToken.value = '' - toast.success('Data source connected', { - description: `${connName.value} has been connected and extraction will begin shortly.`, - }) - wizardOpen.value = false - await loadDataSources() - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'Failed to connect data source' - toast.error('Connection failed', { description: msg }) - // Token is intentionally NOT cleared on failure so the user can retry. + const failedEntries: Array<{ id: string; message: string }> = [] + let successCount = 0 + for (const entry of pendingSources.value) { + try { + await createDataSource({ + kg_id: selectedKnowledgeGraphId.value, + name: entry.name, + adapter_type: 'github', + connection_config: { + repo_url: entry.url, + branch: entry.branch, + }, + credentials: connToken.value ? { access_token: connToken.value } : undefined, + }) + successCount += 1 + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to connect source' + failedEntries.push({ id: entry.id, message }) + } + } + + if (successCount > 0) { + await loadDataSources() + } + + if (failedEntries.length === 0) { + // Clear the plaintext token immediately after the API call succeeds so + // that it does not linger in Vue's reactive state (readable via DevTools). + connToken.value = '' + toast.success('Data sources connected', { + description: `${successCount} source(s) connected successfully.`, + }) + wizardOpen.value = false + return + } + + pendingSources.value = pendingSources.value.filter((entry) => + failedEntries.some((failed) => failed.id === entry.id), + ) + const firstError = failedEntries[0]?.message ?? 'Some sources failed to connect' + if (successCount > 0) { + toast.warning('Some sources were not connected', { + description: `${successCount} succeeded, ${failedEntries.length} failed. ${firstError}`, + }) + } else { + toast.error('Connection failed', { description: firstError }) + } + // Token is intentionally NOT cleared on partial/full failure so the user can retry. } finally { - approvingOntology.value = false + connectingDataSource.value = false } } @@ -713,16 +675,6 @@ async function loadDataSources() { '/management/knowledge-graphs' ) const kgs = kgResult.knowledge_graphs ?? [] - maintenanceKnowledgeGraphs.value = kgs - if (!selectedMaintenanceKnowledgeGraphId.value && kgs.length > 0) { - selectedMaintenanceKnowledgeGraphId.value = kgs[0].id - } - if ( - selectedMaintenanceKnowledgeGraphId.value - && !kgs.some(kg => kg.id === selectedMaintenanceKnowledgeGraphId.value) - ) { - selectedMaintenanceKnowledgeGraphId.value = kgs[0]?.id ?? '' - } const all: DataSourceItem[] = [] for (const kg of kgs) { try { @@ -754,12 +706,8 @@ async function loadDataSources() { } } dataSources.value = all - await loadMaintenanceOrchestration() } catch { dataSources.value = [] - maintenanceKnowledgeGraphs.value = [] - selectedMaintenanceKnowledgeGraphId.value = '' - maintenanceRuns.value = [] } finally { loadingDataSources.value = false } @@ -778,150 +726,6 @@ const hasActiveSyncs = computed(() => }), ) -const telemetryRows = computed(() => - dataSources.value.flatMap((ds) => - (ds.sync_runs ?? []).map(run => ({ ...run, data_source_name: ds.name })), - ), -) - -const telemetryStatusBuckets = computed(() => { - const buckets = { - pending: 0, - ingesting: 0, - ai_extracting: 0, - applying: 0, - completed: 0, - failed: 0, - } - for (const row of telemetryRows.value) { - buckets[row.status] += 1 - } - return buckets -}) - -const telemetryRecentJobs = computed(() => - [...telemetryRows.value] - .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()) - .slice(0, 8), -) - -const telemetryActiveWorkers = computed(() => - telemetryRows.value.filter(row => ACTIVE_STATUSES.includes(row.status)).length, -) - -const telemetryTokenTotal = computed(() => - telemetryRows.value.reduce((sum, row) => sum + (row.token_usage_total ?? 0), 0), -) - -const telemetryCostTotal = computed(() => - telemetryRows.value.reduce((sum, row) => sum + (row.cost_total_usd ?? 0), 0), -) - -const telemetryCostTrend = computed(() => { - const now = Date.now() - const oneDayMs = 24 * 60 * 60 * 1000 - let current = 0 - let previous = 0 - for (const row of telemetryRows.value) { - const eventMs = new Date(row.completed_at ?? row.started_at).getTime() - if (eventMs >= now - oneDayMs) current += row.cost_total_usd ?? 0 - else if (eventMs >= now - oneDayMs * 2) previous += row.cost_total_usd ?? 0 - } - const delta = current - previous - return { current, previous, delta } -}) - -const maintenanceKnowledgeGraphs = ref<Array<{ id: string; name: string }>>([]) -const selectedMaintenanceKnowledgeGraphId = ref('') -const maintenanceSchedule = ref<MaintenanceSchedule>({ - enabled: false, - cron_expression: '0 2 * * *', - timezone_name: 'UTC', - next_run_at: null, -}) -const maintenanceRuns = ref<MaintenanceRun[]>([]) -const maintenanceLoading = ref(false) -const maintenanceSaving = ref(false) -const maintenanceTriggering = ref(false) - -function maintenanceOutcomeTone( - outcome: MaintenanceRun['outcome'], -): 'default' | 'secondary' | 'destructive' { - if (outcome === 'started') return 'default' - if (outcome === 'launch-failed' || outcome === 'preflight-failed') return 'destructive' - return 'secondary' -} - -async function loadMaintenanceOrchestration() { - if (!selectedMaintenanceKnowledgeGraphId.value) { - maintenanceRuns.value = [] - return - } - maintenanceLoading.value = true - try { - const { apiFetch } = useApiClient() - const [schedule, runs] = await Promise.all([ - apiFetch<MaintenanceSchedule>( - `/management/knowledge-graphs/${selectedMaintenanceKnowledgeGraphId.value}/maintenance-schedule`, - ), - apiFetch<{ runs: MaintenanceRun[] }>( - `/management/knowledge-graphs/${selectedMaintenanceKnowledgeGraphId.value}/maintenance-runs`, - ), - ]) - maintenanceSchedule.value = schedule - maintenanceRuns.value = runs.runs ?? [] - } catch { - maintenanceRuns.value = [] - } finally { - maintenanceLoading.value = false - } -} - -async function saveMaintenanceSchedule() { - if (!selectedMaintenanceKnowledgeGraphId.value) return - maintenanceSaving.value = true - try { - const { apiFetch } = useApiClient() - const schedule = await apiFetch<MaintenanceSchedule>( - `/management/knowledge-graphs/${selectedMaintenanceKnowledgeGraphId.value}/maintenance-schedule`, - { - method: 'PUT', - body: { - enabled: maintenanceSchedule.value.enabled, - cron_expression: maintenanceSchedule.value.cron_expression, - timezone_name: maintenanceSchedule.value.timezone_name, - }, - }, - ) - maintenanceSchedule.value = schedule - toast.success('Maintenance schedule saved') - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to save maintenance schedule' - toast.error('Failed to save maintenance schedule', { description: msg }) - } finally { - maintenanceSaving.value = false - } -} - -async function triggerMaintenanceRun() { - if (!selectedMaintenanceKnowledgeGraphId.value) return - maintenanceTriggering.value = true - try { - const { apiFetch } = useApiClient() - await apiFetch( - `/management/knowledge-graphs/${selectedMaintenanceKnowledgeGraphId.value}/maintenance-runs/trigger`, - { method: 'POST' }, - ) - await loadMaintenanceOrchestration() - toast.success('Maintenance orchestration completed') - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to trigger maintenance' - toast.error('Failed to trigger maintenance', { description: msg }) - } finally { - maintenanceTriggering.value = false - } -} - /** Holds the active setInterval handle, or null when not polling. */ const pollInterval = ref<ReturnType<typeof setInterval> | null>(null) @@ -977,20 +781,14 @@ onMounted(async () => { // without auto-opening the creation wizard (see buildDataSourcesStepUrl). const preselectedKgId = route.query.kg_id as string | undefined const fromManage = route.query.from === 'manage' - const focusMaintain = route.query.focus === 'maintain' if (fromManage && preselectedKgId) { scopedKnowledgeGraphId.value = preselectedKgId manageReturnKgId.value = preselectedKgId - selectedMaintenanceKnowledgeGraphId.value = preselectedKgId } else if (preselectedKgId) { await nextTick() openWizard(preselectedKgId) } - - if (focusMaintain && preselectedKgId) { - selectedMaintenanceKnowledgeGraphId.value = preselectedKgId - } }) onUnmounted(() => { @@ -1005,10 +803,6 @@ watch(tenantVersion, () => { loadDataSources() }) -watch(selectedMaintenanceKnowledgeGraphId, () => { - loadMaintenanceOrchestration() -}) - async function triggerSync(dsId: string) { try { const { apiFetch } = useApiClient() @@ -1365,180 +1159,14 @@ async function handleDeleteDs() { </div> <template v-else> - <!-- Extraction operations telemetry dashboard --> - <div class="grid gap-3 md:grid-cols-4"> - <Card> - <CardHeader class="pb-2"> - <CardDescription class="flex items-center gap-1.5 text-[11px]"> - <Cpu class="size-3.5" /> - Active workers - </CardDescription> - <CardTitle class="text-xl">{{ telemetryActiveWorkers }}</CardTitle> - </CardHeader> - <CardContent class="text-[11px] text-muted-foreground"> - Pending {{ telemetryStatusBuckets.pending }} / Ingesting {{ telemetryStatusBuckets.ingesting }} / Extracting {{ telemetryStatusBuckets.ai_extracting }} / Applying {{ telemetryStatusBuckets.applying }} - </CardContent> - </Card> - <Card> - <CardHeader class="pb-2"> - <CardDescription class="flex items-center gap-1.5 text-[11px]"> - <Clock3 class="size-3.5" /> - Recent jobs tracked - </CardDescription> - <CardTitle class="text-xl">{{ telemetryRows.length }}</CardTitle> - </CardHeader> - <CardContent class="text-[11px] text-muted-foreground"> - Completed {{ telemetryStatusBuckets.completed }} / Failed {{ telemetryStatusBuckets.failed }} - </CardContent> - </Card> - <Card> - <CardHeader class="pb-2"> - <CardDescription class="flex items-center gap-1.5 text-[11px]"> - <Coins class="size-3.5" /> - Total token usage - </CardDescription> - <CardTitle class="text-xl">{{ telemetryTokenTotal.toLocaleString() }}</CardTitle> - </CardHeader> - <CardContent class="text-[11px] text-muted-foreground"> - Aggregated from sync-run mutation metadata. - </CardContent> - </Card> - <Card> - <CardHeader class="pb-2"> - <CardDescription class="flex items-center gap-1.5 text-[11px]"> - <DollarSign class="size-3.5" /> - Estimated cost trend - </CardDescription> - <CardTitle class="text-xl">${{ telemetryCostTrend.current.toFixed(2) }}</CardTitle> - </CardHeader> - <CardContent class="text-[11px]" :class="telemetryCostTrend.delta <= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'"> - {{ telemetryCostTrend.delta <= 0 ? 'Down' : 'Up' }} {{ Math.abs(telemetryCostTrend.delta).toFixed(2) }} vs previous 24h - </CardContent> - </Card> - </div> - <Card> <CardHeader class="pb-2"> - <CardTitle class="text-sm">Recent job events</CardTitle> - <CardDescription class="text-xs">Auto-refreshes while active runs are in progress.</CardDescription> - </CardHeader> - <CardContent> - <div v-if="telemetryRecentJobs.length === 0" class="text-xs text-muted-foreground"> - No sync jobs yet. - </div> - <div v-else class="space-y-1.5"> - <div v-for="job in telemetryRecentJobs" :key="job.id" class="flex items-center justify-between rounded border px-2 py-1.5 text-xs"> - <div class="min-w-0"> - <p class="truncate font-medium">{{ job.data_source_name }}</p> - <p class="truncate text-muted-foreground">{{ new Date(job.started_at).toLocaleString() }}</p> - </div> - <div class="flex items-center gap-2"> - <SyncPhaseIndicator :status="job.status" /> - <span class="font-mono text-muted-foreground">{{ job.token_usage_total ?? 0 }} tk</span> - <span class="font-mono text-muted-foreground">${{ (job.cost_total_usd ?? 0).toFixed(2) }}</span> - </div> - </div> - </div> - </CardContent> - </Card> - - <Card> - <CardHeader class="pb-2"> - <CardTitle class="text-sm">Scheduled maintenance orchestration</CardTitle> + <CardTitle class="text-sm">Data source catalog</CardTitle> <CardDescription class="text-xs"> - Configure one schedule per knowledge graph and review launch outcomes. + This page is optimized for source onboarding and source-level actions. + Graph-wide run telemetry and maintenance controls live in the manage workspace. </CardDescription> </CardHeader> - <CardContent class="space-y-3"> - <div class="grid gap-3 md:grid-cols-4"> - <div class="space-y-1"> - <Label class="text-xs">Knowledge graph</Label> - <Select v-model="selectedMaintenanceKnowledgeGraphId"> - <SelectTrigger class="h-8"> - <SelectValue placeholder="Select a knowledge graph" /> - </SelectTrigger> - <SelectContent> - <SelectItem - v-for="kg in maintenanceKnowledgeGraphs" - :key="kg.id" - :value="kg.id" - > - {{ kg.name }} - </SelectItem> - </SelectContent> - </Select> - </div> - <div class="space-y-1"> - <Label class="text-xs">Cron</Label> - <Input v-model="maintenanceSchedule.cron_expression" class="h-8 font-mono text-xs" /> - </div> - <div class="space-y-1"> - <Label class="text-xs">Timezone</Label> - <Input v-model="maintenanceSchedule.timezone_name" class="h-8 text-xs" /> - </div> - <div class="flex items-end gap-2"> - <Button - size="sm" - variant="secondary" - :disabled="!selectedMaintenanceKnowledgeGraphId" - @click="maintenanceSchedule.enabled = !maintenanceSchedule.enabled" - > - {{ maintenanceSchedule.enabled ? 'Disable' : 'Enable' }} - </Button> - <Button - size="sm" - variant="outline" - :disabled="maintenanceSaving || !selectedMaintenanceKnowledgeGraphId" - @click="saveMaintenanceSchedule" - > - <Loader2 v-if="maintenanceSaving" class="mr-1.5 size-3.5 animate-spin" /> - Save schedule - </Button> - <Button - size="sm" - :disabled="maintenanceTriggering || !selectedMaintenanceKnowledgeGraphId" - @click="triggerMaintenanceRun" - > - <Loader2 v-if="maintenanceTriggering" class="mr-1.5 size-3.5 animate-spin" /> - Run now - </Button> - </div> - </div> - <div class="flex items-center justify-between rounded border px-3 py-2 text-xs"> - <p class="text-muted-foreground"> - Next run: - <span class="font-medium text-foreground"> - {{ maintenanceSchedule.next_run_at ? new Date(maintenanceSchedule.next_run_at).toLocaleString() : 'Not scheduled' }} - </span> - </p> - <Badge :variant="maintenanceSchedule.enabled ? 'default' : 'secondary'"> - {{ maintenanceSchedule.enabled ? 'Enabled' : 'Disabled' }} - </Badge> - </div> - <div v-if="maintenanceLoading" class="flex items-center gap-2 text-xs text-muted-foreground"> - <Loader2 class="size-3.5 animate-spin" /> - Loading maintenance run history... - </div> - <div v-else-if="maintenanceRuns.length === 0" class="text-xs text-muted-foreground"> - No maintenance orchestration runs recorded yet. - </div> - <div v-else class="space-y-1.5"> - <div - v-for="run in maintenanceRuns" - :key="run.run_id" - class="flex items-center justify-between rounded border px-2 py-1.5 text-xs" - > - <div> - <p class="font-medium">{{ new Date(run.triggered_at).toLocaleString() }}</p> - <p class="text-muted-foreground">{{ run.message ?? 'No message provided' }}</p> - </div> - <div class="flex items-center gap-2"> - <Badge :variant="maintenanceOutcomeTone(run.outcome)">{{ run.outcome }}</Badge> - <span class="text-muted-foreground">{{ run.target_data_source_ids.length }} sources</span> - </div> - </div> - </div> - </CardContent> </Card> <!-- Empty state (no data sources yet) --> @@ -1772,11 +1400,13 @@ async function handleDeleteDs() { </template> </div> - <!-- ── Step 1: Select Adapter ── --> + <!-- ── Step 1: Bulk URL entry ── --> <div v-if="wizardStep === 1" class="space-y-4"> <div> - <h3 class="text-sm font-semibold">Select an adapter type</h3> - <p class="text-xs text-muted-foreground">Choose the system you want to import data from.</p> + <h3 class="text-sm font-semibold">Paste your source URLs</h3> + <p class="text-xs text-muted-foreground"> + Add one source at a time with "Add another". We auto-detect provider and prepare all supported sources at once. + </p> </div> <!-- Knowledge graph selection --> @@ -1798,44 +1428,59 @@ async function handleDeleteDs() { </p> </div> - <div class="grid gap-3 sm:grid-cols-2"> - <button - v-for="adapter in adapters" - :key="adapter.id" - :disabled="!adapter.available" - class="group relative rounded-lg border p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" - :class="[ - adapter.available - ? selectedAdapterId === adapter.id - ? 'border-primary bg-primary/5' - : 'hover:border-primary/50 hover:bg-accent' - : 'cursor-not-allowed opacity-50', - ]" - @click="adapter.available && selectAdapter(adapter.id)" + <div class="space-y-2"> + <Label>Data source URLs <span class="text-destructive">*</span></Label> + <div + v-for="(row, idx) in sourceUrlInputs" + :key="row.id" + class="rounded-md border p-2" > - <div class="flex items-start gap-3"> - <div class="rounded-md bg-muted p-2 shrink-0"> - <component :is="adapter.icon" class="size-5 text-muted-foreground" /> - </div> - <div class="flex-1 min-w-0"> - <div class="flex items-center gap-2"> - <p class="text-sm font-medium">{{ adapter.label }}</p> - <Badge v-if="!adapter.available" variant="outline" class="text-[10px] px-1.5 py-0"> - Soon - </Badge> - <CheckCircle2 - v-if="selectedAdapterId === adapter.id" - class="ml-auto size-4 text-primary shrink-0" - /> - </div> - <p class="text-xs text-muted-foreground mt-0.5">{{ adapter.description }}</p> - </div> + <div class="flex items-start gap-2"> + <Input + v-model="row.url" + :placeholder="`https://github.com/owner/repository-${idx + 1}`" + /> + <Button + v-if="sourceUrlInputs.length > 1" + type="button" + variant="ghost" + size="sm" + class="h-9 shrink-0" + @click="removeSourceInput(row.id)" + > + Remove + </Button> + </div> + <div v-if="row.url.trim()" class="mt-2 flex items-center gap-2 text-xs text-muted-foreground"> + <span>Detected:</span> + <Badge :variant="detectAdapterFromUrl(row.url) === 'github' ? 'default' : 'outline'"> + {{ providerLabel(detectAdapterFromUrl(row.url)) }} + </Badge> </div> - </button> + </div> + <div class="flex items-center gap-2"> + <Button type="button" variant="outline" size="sm" @click="addSourceInput()"> + Add another + </Button> + </div> + <p v-if="sourceUrlError" class="text-xs text-destructive">{{ sourceUrlError }}</p> + <p + v-if="providerError" + class="text-xs" + :class="providerError.includes('Unknown') ? 'text-destructive' : 'text-amber-600 dark:text-amber-400'" + > + {{ providerError }} + </p> + <p v-else class="text-xs text-muted-foreground"> + GitHub is fully supported now. GitLab and Jira are detected and shown as coming soon. + </p> </div> <DialogFooter class="pt-2"> - <Button :disabled="!selectedAdapterId" @click="nextStep"> + <Button + :disabled="!selectedKnowledgeGraphId || sourceUrlInputs.every((entry) => !entry.url.trim())" + @click="nextStep" + > Continue <ChevronRight class="ml-1 size-4" /> </Button> @@ -1845,76 +1490,75 @@ async function handleDeleteDs() { <!-- ── Step 2: Connection Configuration ── --> <div v-else-if="wizardStep === 2" class="space-y-5"> <div> - <h3 class="text-sm font-semibold">Configure connection</h3> + <h3 class="text-sm font-semibold">Confirm connection details</h3> <p class="text-xs text-muted-foreground"> - Provide the details to connect your - <span class="font-medium">{{ selectedAdapter?.label }}</span> repository. + Review each detected source, adjust inferred name/branch if needed, then connect them all at once. </p> </div> - <!-- GitHub-specific fields --> - <div v-if="selectedAdapterId === 'github'" class="space-y-4"> - <div class="space-y-1.5"> - <Label for="ds-repo-url"> - Repository URL <span class="text-destructive">*</span> - </Label> - <Input - id="ds-repo-url" - v-model="connRepoUrl" - placeholder="https://github.com/owner/repository" - @input="connRepoUrlError = ''" - /> - <p v-if="connRepoUrlError" class="text-xs text-destructive">{{ connRepoUrlError }}</p> - <p v-else class="text-xs text-muted-foreground"> - The full HTTPS URL of the GitHub repository to index. - </p> - </div> - - <div class="space-y-1.5"> - <Label for="ds-token"> - Access Token <span class="text-destructive">*</span> - </Label> - <div class="relative"> - <Input - id="ds-token" - v-model="connToken" - :type="showToken ? 'text' : 'password'" - placeholder="ghp_••••••••••••••••••••••••••••••••••••" - class="pr-10" - @input="connTokenError = ''" - /> - <Button - variant="ghost" - size="icon" - class="absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground" - type="button" - @click="showToken = !showToken" - > - <Eye v-if="!showToken" class="size-3.5" /> - <EyeOff v-else class="size-3.5" /> - </Button> + <div class="space-y-3"> + <div + v-for="entry in pendingSources" + :key="entry.id" + class="space-y-2 rounded-md border p-3" + > + <div class="flex items-center justify-between gap-2"> + <p class="text-xs font-mono break-all">{{ entry.url }}</p> + <Badge variant="secondary">{{ providerLabel(entry.detectedAdapterId) }}</Badge> </div> - <p v-if="connTokenError" class="text-xs text-destructive">{{ connTokenError }}</p> - <p v-else class="text-xs text-muted-foreground"> - A GitHub personal access token with <code class="rounded bg-muted px-0.5">read:repo</code> scope. - </p> + <div class="grid gap-3 md:grid-cols-2"> + <div class="space-y-1.5"> + <Label>Data Source Name <span class="text-destructive">*</span></Label> + <Input + v-model="entry.name" + placeholder="e.g. my-repository" + @input="entry.nameError = ''" + /> + <p v-if="entry.nameError" class="text-xs text-destructive">{{ entry.nameError }}</p> + </div> + <div class="space-y-1.5"> + <Label>Tracked Branch <span class="text-destructive">*</span></Label> + <Input + v-model="entry.branch" + placeholder="main" + @input="entry.branchError = ''" + /> + <p v-if="entry.branchError" class="text-xs text-destructive">{{ entry.branchError }}</p> + <p v-else class="text-xs text-muted-foreground">Default branch is auto-detected when available.</p> + </div> + </div> + <p v-if="entry.urlError" class="text-xs text-destructive">{{ entry.urlError }}</p> </div> + </div> - <div class="space-y-1.5"> - <Label for="ds-name"> - Data Source Name <span class="text-destructive">*</span> - </Label> + <div class="space-y-1.5"> + <Label for="ds-token"> + Access Token (optional) + </Label> + <div class="relative"> <Input - id="ds-name" - v-model="connName" - placeholder="e.g. my-repository" - @input="connNameError = ''" + id="ds-token" + v-model="connToken" + :type="showToken ? 'text' : 'password'" + placeholder="ghp_••••••••••••••••••••••••••••••••••••" + class="pr-10" + @input="connTokenError = ''" /> - <p v-if="connNameError" class="text-xs text-destructive">{{ connNameError }}</p> - <p v-else class="text-xs text-muted-foreground"> - Auto-inferred from the repository URL. You can rename it here. - </p> + <Button + variant="ghost" + size="icon" + class="absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground" + type="button" + @click="showToken = !showToken" + > + <Eye v-if="!showToken" class="size-3.5" /> + <EyeOff v-else class="size-3.5" /> + </Button> </div> + <p v-if="connTokenError" class="text-xs text-destructive">{{ connTokenError }}</p> + <p v-else class="text-xs text-muted-foreground"> + A GitHub personal access token with <code class="rounded bg-muted px-0.5">read:repo</code> scope. + </p> </div> <!-- Credential security note --> @@ -1931,306 +1575,13 @@ async function handleDeleteDs() { <ChevronLeft class="mr-1 size-4" /> Back </Button> - <Button @click="nextStep"> - Continue - <ChevronRight class="ml-1 size-4" /> + <Button :disabled="connectingDataSource || detectingSourceDetails" @click="nextStep"> + <Loader2 v-if="connectingDataSource || detectingSourceDetails" class="mr-1 size-4 animate-spin" /> + Add to project </Button> </DialogFooter> </div> - <!-- ── Step 3: Intent Description ── --> - <div v-else-if="wizardStep === 3" class="space-y-5"> - <div> - <h3 class="text-sm font-semibold">Describe your intent</h3> - <p class="text-xs text-muted-foreground"> - Tell the AI agent what problems or questions you want to solve with this data. - This shapes the proposed knowledge graph ontology. - </p> - </div> - - <div class="space-y-1.5"> - <Label for="intent-text">What do you want to learn from this data?</Label> - <textarea - id="intent-text" - v-model="intentText" - placeholder="e.g. I want to understand how issues are triaged, who the most active contributors are, and how pull requests relate to releases…" - class="flex min-h-[120px] w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50" - @input="intentError = ''" - /> - <p v-if="intentError" class="text-xs text-destructive">{{ intentError }}</p> - <p v-else class="text-xs text-muted-foreground"> - The more specific you are, the better the proposed ontology will match your needs. - </p> - </div> - - <DialogFooter class="pt-2"> - <Button variant="outline" @click="prevStep"> - <ChevronLeft class="mr-1 size-4" /> - Back - </Button> - <Button @click="nextStep"> - Analyse & Propose Ontology - <ChevronRight class="ml-1 size-4" /> - </Button> - </DialogFooter> - </div> - - <!-- ── Step 4: Review Proposed Ontology ── --> - <div v-else-if="wizardStep === 4" class="space-y-4"> - <!-- Scanning state --> - <div v-if="scanningOntology" class="flex flex-col items-center gap-4 py-10 text-center"> - <Loader2 class="size-10 animate-spin text-primary" /> - <div> - <p class="text-sm font-medium">Analysing your data source…</p> - <p class="text-xs text-muted-foreground"> - Scanning repository structure and applying your intent to propose an ontology. - </p> - </div> - </div> - - <!-- Proposed ontology --> - <template v-else-if="ontologyReady"> - <div> - <h3 class="text-sm font-semibold">Review proposed ontology</h3> - <p class="text-xs text-muted-foreground"> - The AI agent has proposed the following node and edge types based on your data source and intent. - You can edit or remove individual types before approving. - </p> - </div> - - <!-- Re-extraction warning note --> - <div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30"> - <AlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" /> - <p class="text-xs text-amber-700 dark:text-amber-300"> - Modifying the ontology after the initial extraction is complete will trigger a full - re-extraction of this data source. Approve carefully. - </p> - </div> - - <!-- Node types --> - <div class="space-y-2"> - <h4 class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> - Node Types ({{ proposedNodes.length }}) - </h4> - <div class="space-y-2"> - <Card - v-for="(node, idx) in proposedNodes" - :key="idx" - class="overflow-hidden" - > - <!-- View mode --> - <CardContent v-if="!node.editing" class="flex items-start gap-3 p-3"> - <Badge variant="default" class="mt-0.5 shrink-0">Node</Badge> - <div class="flex-1 min-w-0"> - <p class="text-sm font-medium">{{ node.label }}</p> - <p class="text-xs text-muted-foreground">{{ node.description }}</p> - <div class="mt-1.5 flex flex-wrap gap-1"> - <Badge - v-for="prop in node.required_properties" - :key="prop" - variant="secondary" - class="text-[10px]" - > - {{ prop }} <span class="ml-0.5 text-destructive">*</span> - </Badge> - <Badge - v-for="prop in node.optional_properties" - :key="prop" - variant="outline" - class="text-[10px]" - > - {{ prop }} - </Badge> - </div> - </div> - <div class="flex shrink-0 items-center gap-1"> - <Tooltip> - <TooltipTrigger as-child> - <Button variant="ghost" size="icon" class="size-7" @click="startEditNode(idx)"> - <Pencil class="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent><p>Edit type</p></TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger as-child> - <Button variant="ghost" size="icon" class="size-7 text-destructive hover:text-destructive" @click="removeNode(idx)"> - <Trash2 class="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent><p>Remove type</p></TooltipContent> - </Tooltip> - </div> - </CardContent> - - <!-- Edit mode --> - <CardContent v-else class="space-y-3 p-3"> - <div class="grid grid-cols-2 gap-3"> - <div class="space-y-1"> - <Label class="text-xs">Label</Label> - <Input v-model="node.editLabel" class="h-8 text-xs" @input="node.editError = ''" /> - <p v-if="node.editError" class="text-xs text-destructive">{{ node.editError }}</p> - </div> - <div class="space-y-1"> - <Label class="text-xs">Description</Label> - <Input v-model="node.editDescription" class="h-8 text-xs" /> - </div> - </div> - <div class="grid grid-cols-2 gap-3"> - <div class="space-y-1"> - <Label class="text-xs">Required properties <span class="text-muted-foreground">(comma-separated)</span></Label> - <Input v-model="node.editRequired" placeholder="e.g. name, url" class="h-8 text-xs" /> - </div> - <div class="space-y-1"> - <Label class="text-xs">Optional properties</Label> - <Input v-model="node.editOptional" placeholder="e.g. description, stars" class="h-8 text-xs" /> - </div> - </div> - <div class="flex justify-end gap-2"> - <Button variant="ghost" size="sm" class="h-7 text-xs" @click="cancelEditNode(idx)"> - <X class="mr-1 size-3" /> - Cancel - </Button> - <Button size="sm" class="h-7 text-xs" @click="saveEditNode(idx)"> - <Check class="mr-1 size-3" /> - Save - </Button> - </div> - </CardContent> - </Card> - </div> - <!-- Add Node Type button --> - <Button variant="outline" size="sm" class="mt-2 w-full gap-2" @click="addNode"> - <Plus class="size-4" /> - Add Node Type - </Button> - </div> - - <!-- Edge types --> - <div class="space-y-2"> - <h4 class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> - Edge Types ({{ proposedEdges.length }}) - </h4> - <div class="space-y-2"> - <Card - v-for="(edge, idx) in proposedEdges" - :key="idx" - class="overflow-hidden" - > - <!-- View mode --> - <CardContent v-if="!edge.editing" class="flex items-start gap-3 p-3"> - <Badge variant="outline" class="mt-0.5 shrink-0">Edge</Badge> - <div class="flex-1 min-w-0"> - <p class="text-sm font-medium font-mono">{{ edge.label }}</p> - <p class="text-xs text-muted-foreground">{{ edge.description }}</p> - <p class="text-xs text-muted-foreground/70 mt-0.5"> - {{ edge.from }} → {{ edge.to }} - </p> - <div v-if="edge.required_properties.length || edge.optional_properties.length" class="mt-1.5 flex flex-wrap gap-1"> - <Badge - v-for="prop in edge.required_properties" - :key="prop" - variant="secondary" - class="text-[10px]" - > - {{ prop }} <span class="ml-0.5 text-destructive">*</span> - </Badge> - <Badge - v-for="prop in edge.optional_properties" - :key="prop" - variant="outline" - class="text-[10px]" - > - {{ prop }} - </Badge> - </div> - </div> - <div class="flex shrink-0 items-center gap-1"> - <Tooltip> - <TooltipTrigger as-child> - <Button variant="ghost" size="icon" class="size-7" @click="startEditEdge(idx)"> - <Pencil class="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent><p>Edit type</p></TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger as-child> - <Button variant="ghost" size="icon" class="size-7 text-destructive hover:text-destructive" @click="removeEdge(idx)"> - <Trash2 class="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent><p>Remove type</p></TooltipContent> - </Tooltip> - </div> - </CardContent> - - <!-- Edit mode --> - <CardContent v-else class="space-y-3 p-3"> - <div class="grid grid-cols-2 gap-3"> - <div class="space-y-1"> - <Label class="text-xs">Label</Label> - <Input v-model="edge.editLabel" class="h-8 text-xs" @input="edge.editError = ''" /> - <p v-if="edge.editError" class="text-xs text-destructive">{{ edge.editError }}</p> - </div> - <div class="space-y-1"> - <Label class="text-xs">Description</Label> - <Input v-model="edge.editDescription" class="h-8 text-xs" /> - </div> - </div> - <div class="grid grid-cols-2 gap-3"> - <div class="space-y-1"> - <Label class="text-xs">From type</Label> - <Input v-model="edge.from" placeholder="e.g. Repository" class="h-8 text-xs" /> - </div> - <div class="space-y-1"> - <Label class="text-xs">To type</Label> - <Input v-model="edge.to" placeholder="e.g. Issue" class="h-8 text-xs" /> - </div> - </div> - <div class="grid grid-cols-2 gap-3"> - <div class="space-y-1"> - <Label class="text-xs">Required properties</Label> - <Input v-model="edge.editRequired" placeholder="comma-separated" class="h-8 text-xs" /> - </div> - <div class="space-y-1"> - <Label class="text-xs">Optional properties</Label> - <Input v-model="edge.editOptional" placeholder="comma-separated" class="h-8 text-xs" /> - </div> - </div> - <div class="flex justify-end gap-2"> - <Button variant="ghost" size="sm" class="h-7 text-xs" @click="cancelEditEdge(idx)"> - <X class="mr-1 size-3" /> - Cancel - </Button> - <Button size="sm" class="h-7 text-xs" @click="saveEditEdge(idx)"> - <Check class="mr-1 size-3" /> - Save - </Button> - </div> - </CardContent> - </Card> - </div> - <!-- Add Edge Type button --> - <Button variant="outline" size="sm" class="mt-2 w-full gap-2" @click="addEdge"> - <Plus class="size-4" /> - Add Edge Type - </Button> - </div> - </template> - - <DialogFooter v-if="!scanningOntology" class="pt-2"> - <Button variant="outline" @click="prevStep"> - <ChevronLeft class="mr-1 size-4" /> - Back - </Button> - <Button :disabled="!ontologyReady || approvingOntology" @click="approveOntology"> - <Loader2 v-if="approvingOntology" class="mr-2 size-4 animate-spin" /> - <CheckCircle2 v-else class="mr-2 size-4" /> - Approve & Start Extraction - </Button> - </DialogFooter> - </div> </DialogContent> </Dialog> diff --git a/src/dev-ui/app/tests/data-source-connection-wizard.test.ts b/src/dev-ui/app/tests/data-source-connection-wizard.test.ts index 6ab430ca1..4b78fe829 100644 --- a/src/dev-ui/app/tests/data-source-connection-wizard.test.ts +++ b/src/dev-ui/app/tests/data-source-connection-wizard.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi } from 'vitest' import { ADAPTERS, + detectAdapterFromUrl, + parseSourceUrls, inferNameFromRepoUrl, canAdvanceStep1, isAdapterSelectable, + validateStep1, validateStep2, buildDataSourceCreationUrl, buildDataSourceCreationBody, @@ -26,6 +29,32 @@ import { // ── Group 1: Adapter selection (Step 1) ─────────────────────────────────────── describe('Data Source Connection Wizard — Group 1: Adapter selection', () => { + it('test_detects_github_gitlab_and_jira_from_source_urls', () => { + expect(detectAdapterFromUrl('https://github.com/acme/repo')).toBe('github') + expect(detectAdapterFromUrl('https://gitlab.com/acme/repo')).toBe('gitlab') + expect(detectAdapterFromUrl('https://acme.atlassian.net/browse/PROJ-1')).toBe('jira') + }) + + it('test_returns_unknown_for_unrecognized_or_invalid_url', () => { + expect(detectAdapterFromUrl('https://example.com/repo')).toBe('unknown') + expect(detectAdapterFromUrl('not-a-url')).toBe('unknown') + }) + + it('test_bulk_url_parser_normalizes_multiline_entries', () => { + const parsed = parseSourceUrls(` + https://github.com/acme/repo-1 + https://github.com/acme/repo-2 + + https://github.com/acme/repo-1 + `) + expect(parsed).toHaveLength(2) + expect(parsed.map((entry) => entry.url)).toEqual([ + 'https://github.com/acme/repo-1', + 'https://github.com/acme/repo-2', + ]) + expect(parsed.every((entry) => entry.detectedAdapterId === 'github')).toBe(true) + }) + it('test_github_is_the_only_available_adapter', () => { // The adapters list has exactly one available adapter and it is GitHub. // This is a regression guard: adding a new adapter without updating this @@ -78,6 +107,27 @@ describe('Data Source Connection Wizard — Group 1: Adapter selection', () => { expect(canAdvanceStep1('github', 'kg-123')).toBe(true) }) + it('test_step1_validation_rejects_unavailable_detected_provider', () => { + const result = validateStep1({ + selectedKnowledgeGraphId: 'kg-1', + sourceUrl: 'https://gitlab.com/acme/repo', + detectedAdapterId: 'gitlab', + }) + expect(result.valid).toBe(false) + expect(result.providerError).toContain('coming soon') + }) + + it('test_step1_validation_accepts_github_url_with_selected_kg', () => { + const result = validateStep1({ + selectedKnowledgeGraphId: 'kg-1', + sourceUrl: 'https://github.com/acme/repo', + detectedAdapterId: 'github', + }) + expect(result.valid).toBe(true) + expect(result.sourceUrlError).toBe('') + expect(result.providerError).toBe('') + }) + it('test_unavailable_adapter_blocks_step1_advancement', () => { // Even if selectedAdapterId is set to an unavailable adapter it cannot // advance — selecting such an adapter should be blocked at selection time @@ -107,9 +157,9 @@ describe('Data Source Connection Wizard — Group 2: Connection configuration', expect(name).toBe('repo') }) - it('test_name_inference_returns_null_for_non_github_url', () => { - // Non-GitHub URLs return null so the caller can leave the name unchanged. - expect(inferNameFromRepoUrl('https://gitlab.com/org/repo')).toBeNull() + it('test_name_inference_supports_git_host_urls_and_returns_null_for_invalid', () => { + // Git host URLs can infer repository names; invalid strings return null. + expect(inferNameFromRepoUrl('https://gitlab.com/org/repo')).toBe('repo') expect(inferNameFromRepoUrl('not-a-url')).toBeNull() expect(inferNameFromRepoUrl('')).toBeNull() }) diff --git a/src/dev-ui/app/tests/data-sources.test.ts b/src/dev-ui/app/tests/data-sources.test.ts index 5b0c38b6c..340fb2828 100644 --- a/src/dev-ui/app/tests/data-sources.test.ts +++ b/src/dev-ui/app/tests/data-sources.test.ts @@ -3139,7 +3139,7 @@ describe('Data Sources — kg_id query param pre-selects KG and opens wizard (Ta }) }) -describe('Extraction telemetry dashboard - structural verification', () => { +describe('Data-sources-focused layout - structural verification', () => { const { readFileSync } = require('fs') const { resolve } = require('path') const source = readFileSync( @@ -3147,42 +3147,52 @@ describe('Extraction telemetry dashboard - structural verification', () => { 'utf-8', ) - it('declares telemetry status buckets and recent jobs computeds', () => { - expect(source).toContain('telemetryStatusBuckets') - expect(source).toContain('telemetryRecentJobs') + it('keeps data-source catalog guidance and removes telemetry dashboard copy', () => { + expect(source).toContain('Data source catalog') + expect(source).not.toContain('Active workers') + expect(source).not.toContain('Estimated cost trend') }) - it('renders active worker and token usage cards', () => { - expect(source).toContain('Active workers') - expect(source).toContain('Total token usage') + it('removes scheduled maintenance orchestration from this page', () => { + expect(source).not.toContain('Scheduled maintenance orchestration') + expect(source).not.toContain('maintenance-runs/trigger') }) - it('renders estimated cost trend with 24h comparison', () => { - expect(source).toContain('Estimated cost trend') - expect(source).toContain('previous 24h') + it('renders URL-first onboarding with provider detection and coming soon messaging', () => { + expect(source).toContain('Paste your source URLs') + expect(source).toContain('Add another') + expect(source).toContain('Detected:') + expect(source).toContain('onboarding is coming soon, sorry.') + expect(source).toContain('Add to project') }) }) -describe('Scheduled maintenance orchestration - structural verification', () => { - const source = readFileSync( - resolve(__dirname, '../pages/data-sources/index.vue'), - 'utf-8', - ) - - it('declares maintenance schedule state and loader function', () => { - expect(source).toContain('maintenanceSchedule') - expect(source).toContain('loadMaintenanceOrchestration') - }) +describe('Bulk onboarding partial-success behavior', () => { + it('retains only failed entries when batch create is partially successful', async () => { + const pendingSources = [ + { id: '1', name: 'repo-one', url: 'https://github.com/acme/repo-one', branch: 'main' }, + { id: '2', name: 'repo-two', url: 'https://github.com/acme/repo-two', branch: 'main' }, + { id: '3', name: 'repo-three', url: 'https://github.com/acme/repo-three', branch: 'main' }, + ] + const createDataSource = vi.fn() + .mockResolvedValueOnce({ id: 'ds-1' }) + .mockRejectedValueOnce(new Error('token invalid')) + .mockResolvedValueOnce({ id: 'ds-3' }) + const failedIds: string[] = [] + let successCount = 0 - it('renders the scheduled maintenance panel and trigger action', () => { - expect(source).toContain('Scheduled maintenance orchestration') - expect(source).toContain('maintenance-runs/trigger') - expect(source).toContain('Run now') - }) + for (const entry of pendingSources) { + try { + await createDataSource(entry) + successCount += 1 + } catch { + failedIds.push(entry.id) + } + } - it('renders maintenance outcome history list', () => { - expect(source).toContain('No maintenance orchestration runs recorded yet.') - expect(source).toContain('maintenanceOutcomeTone') + const remaining = pendingSources.filter((entry) => failedIds.includes(entry.id)) + expect(successCount).toBe(2) + expect(remaining.map((entry) => entry.id)).toEqual(['2']) }) }) diff --git a/src/dev-ui/app/tests/task-121-spec-alignment.test.ts b/src/dev-ui/app/tests/task-121-spec-alignment.test.ts index 4e25cd661..63eda2df6 100644 --- a/src/dev-ui/app/tests/task-121-spec-alignment.test.ts +++ b/src/dev-ui/app/tests/task-121-spec-alignment.test.ts @@ -3,9 +3,11 @@ import { readFileSync } from 'fs' import { resolve } from 'path' import { ADAPTERS, + detectAdapterFromUrl, isAdapterSelectable, canAdvanceStep1, inferNameFromRepoUrl, + validateStep1, validateStep2, buildDataSourceCreationUrl, buildDataSourceCreationBody, @@ -111,12 +113,12 @@ describe('Task-121 — Requirement: Knowledge Graph Creation', () => { describe('Task-121 — Requirement: Data Source Connection — Adapter & Configuration', () => { describe('data-sources page imports and uses dataSourceWizard utilities', () => { - it('imports ADAPTERS from dataSourceWizard', () => { - expect(DS_INDEX_VUE).toContain('ADAPTERS') + it('imports detectAdapterFromUrl from dataSourceWizard', () => { + expect(DS_INDEX_VUE).toContain('detectAdapterFromUrl') }) - it('imports canAdvanceStep1 from dataSourceWizard', () => { - expect(DS_INDEX_VUE).toContain('canAdvanceStep1') + it('imports validateStep1 from dataSourceWizard', () => { + expect(DS_INDEX_VUE).toContain('validateStep1') }) it('imports validateStep2 from dataSourceWizard', () => { @@ -149,17 +151,32 @@ describe('Task-121 — Requirement: Data Source Connection — Adapter & Configu }) }) - describe('Step 1 advancement requires both adapter AND knowledge graph', () => { - it('blocked when adapter is missing even with KG selected', () => { - expect(canAdvanceStep1('', 'kg-123')).toBe(false) + describe('Step 1 validation enforces URL detection and provider availability', () => { + it('detects supported and unsupported providers from URL', () => { + expect(detectAdapterFromUrl('https://github.com/acme/repo')).toBe('github') + expect(detectAdapterFromUrl('https://gitlab.com/acme/repo')).toBe('gitlab') + expect(detectAdapterFromUrl('https://acme.atlassian.net/browse/ABC-1')).toBe('jira') }) - it('blocked when KG is missing even with adapter selected', () => { - expect(canAdvanceStep1('github', '')).toBe(false) + it('blocks advancement when provider is unsupported', () => { + const result = validateStep1({ + selectedKnowledgeGraphId: 'kg-123', + sourceUrl: 'https://gitlab.com/acme/repo', + detectedAdapterId: 'gitlab', + }) + expect(result.valid).toBe(false) + expect(result.providerError).toContain('coming soon') }) - it('allowed when both adapter and KG are selected', () => { - expect(canAdvanceStep1('github', 'kg-123')).toBe(true) + it('allows advancement for valid GitHub URL and selected KG', () => { + const result = validateStep1({ + selectedKnowledgeGraphId: 'kg-123', + sourceUrl: 'https://github.com/acme/repo', + detectedAdapterId: 'github', + }) + expect(result.valid).toBe(true) + expect(result.sourceUrlError).toBe('') + expect(result.providerError).toBe('') }) }) @@ -172,8 +189,8 @@ describe('Task-121 — Requirement: Data Source Connection — Adapter & Configu expect(inferNameFromRepoUrl('https://github.com/org/repo.git')).toBe('repo') }) - it('returns null for non-GitHub URLs (no overwrite)', () => { - expect(inferNameFromRepoUrl('https://gitlab.com/org/repo')).toBeNull() + it('supports name inference for GitHub and GitLab repository URLs', () => { + expect(inferNameFromRepoUrl('https://gitlab.com/org/repo')).toBe('repo') expect(inferNameFromRepoUrl('')).toBeNull() }) }) diff --git a/src/dev-ui/app/tests/task-129-spec-alignment.test.ts b/src/dev-ui/app/tests/task-129-spec-alignment.test.ts index 16845f910..6ff882e1d 100644 --- a/src/dev-ui/app/tests/task-129-spec-alignment.test.ts +++ b/src/dev-ui/app/tests/task-129-spec-alignment.test.ts @@ -873,90 +873,31 @@ describe('Task-129 — Scenario: Default landing', () => { }) }) -// ── Requirement: Ontology Design — Scenario: Intent description ─────────────── +// ── Requirement: Data Source Connection — URL-first onboarding ──────────────── // -// Spec: "GIVEN a user who has connected a data source -// WHEN the connection is saved -// THEN the user is prompted to describe (in free text) what problems or -// questions they want to solve with this data" +// Spec: URL-first flow with provider auto-detection and coming-soon handling. -describe('Task-129 — Scenario: Intent description', () => { - it('data-sources page has an intentText ref for the free-text description prompt', () => { - // Step 3 of the wizard: the user describes their intent before ontology proposal - expect(dataSourcesVue).toContain('intentText') +describe('Task-129 — Scenario: URL-first data source onboarding', () => { + it('data-sources page prompts for source URL first', () => { + expect(dataSourcesVue).toContain('Paste your source URLs') + expect(dataSourcesVue).toContain('sourceUrlInputs') + expect(dataSourcesVue).toContain('Add another') }) - it('intentText is validated before submitting — intentError is shown if invalid', () => { - expect(dataSourcesVue).toContain('intentError') - expect(dataSourcesVue).toContain('validateIntentText') + it('provider detection is shown with explicit coming-soon messaging', () => { + expect(dataSourcesVue).toContain('Detected provider:') + expect(dataSourcesVue).toContain('onboarding is coming soon, sorry.') }) - it('submitting intent calls beginOntologyProposal()', () => { - // After valid intent, the system starts the proposal flow - expect(dataSourcesVue).toContain('beginOntologyProposal') - }) -}) - -// ── Requirement: Ontology Design — Scenario: Agent-proposed ontology ────────── -// -// Spec: "GIVEN a free-text intent description and a connected data source -// WHEN the user submits their intent -// THEN the system performs a lightweight scan of the data source -// AND an AI agent explores the scanned data and proposes an ontology -// AND the proposed ontology is presented to the user for review" - -describe('Task-129 — Scenario: Agent-proposed ontology', () => { - it('data-sources page has proposedNodes ref for the agent-proposed node types', () => { - expect(dataSourcesVue).toContain('proposedNodes') - }) - - it('data-sources page has proposedEdges ref for the agent-proposed edge types', () => { - expect(dataSourcesVue).toContain('proposedEdges') - }) - - it('data-sources page has ontologyReady ref — true when proposal is ready for review', () => { - // ontologyReady = true signals the wizard to show the review step - expect(dataSourcesVue).toContain('ontologyReady') + it('wizard validates provider/URL through validateStep1 before advancing', () => { + expect(dataSourcesVue).toContain('validateStep1') + expect(dataSourcesVue).toContain('detectAdapterFromUrl') }) - it('beginOntologyProposal() resets proposal state before re-fetching', () => { - // Clearing previous state prevents stale proposal data from being shown - expect(dataSourcesVue).toContain('proposedNodes.value = []') - expect(dataSourcesVue).toContain('proposedEdges.value = []') - }) - - it('beginOntologyProposal() sets ontologyReady to true after proposal is complete', () => { - expect(dataSourcesVue).toContain('ontologyReady.value = true') - }) -}) - -// ── Requirement: Ontology Design — Scenario: Ontology review and approval ───── -// -// Spec: "GIVEN a proposed ontology -// WHEN the user reviews it -// THEN they can approve the ontology as-is -// OR iterate by editing individual types and relationships -// AND extraction begins only after the user explicitly approves" - -describe('Task-129 — Scenario: Ontology review and approval', () => { - it('data-sources page has an approveOntology() function that triggers extraction', () => { - // Spec: "extraction begins only after the user explicitly approves" - expect(dataSourcesVue).toContain('approveOntology') - }) - - it('approve button is disabled until ontologyReady is true', () => { - // Prevents the user from approving before the proposal has loaded - expect(dataSourcesVue).toContain(':disabled="!ontologyReady') - }) - - it('approvingOntology flag prevents double submission on approval', () => { - expect(dataSourcesVue).toContain('approvingOntology') - }) - - it('approval step is the final step in the wizard — extraction follows approval', () => { - // The wizard step after review/approval creates the data source and starts sync - expect(dataSourcesVue).toContain('approveOntology') - expect(dataSourcesVue).toContain('triggerSync') + it('connection confirmation includes tracked branch and one-time token entry', () => { + expect(dataSourcesVue).toContain('Tracked Branch') + expect(dataSourcesVue).toContain('Add to project') + expect(dataSourcesVue).toContain('Access Token (optional)') }) }) diff --git a/src/dev-ui/app/utils/dataSourceWizard.ts b/src/dev-ui/app/utils/dataSourceWizard.ts index 4fc149883..419b3f73d 100644 --- a/src/dev-ui/app/utils/dataSourceWizard.ts +++ b/src/dev-ui/app/utils/dataSourceWizard.ts @@ -24,6 +24,8 @@ export interface AdapterDefinition { available: boolean } +export type DetectedAdapterId = 'github' | 'gitlab' | 'jira' | 'unknown' + /** * The canonical list of supported (and unavailable/future) adapters. * @@ -51,6 +53,50 @@ export const ADAPTERS: AdapterDefinition[] = [ }, ] +/** + * Best-effort adapter detection from a source URL hostname/path. + */ +export function detectAdapterFromUrl(url: string): DetectedAdapterId { + if (!url.trim()) return 'unknown' + try { + const parsed = new URL(url.trim()) + const host = parsed.hostname.toLowerCase() + const path = parsed.pathname.toLowerCase() + if (host.includes('github.com')) return 'github' + if (host.includes('gitlab.com')) return 'gitlab' + if (host.includes('atlassian.net') || path.includes('/jira') || path.includes('/browse/')) { + return 'jira' + } + return 'unknown' + } catch { + return 'unknown' + } +} + +export interface ParsedSourceUrl { + url: string + detectedAdapterId: DetectedAdapterId +} + +/** + * Parses a multiline bulk-input field into normalized URL entries. + * Empty lines are ignored and exact duplicates are removed. + */ +export function parseSourceUrls(input: string): ParsedSourceUrl[] { + const seen = new Set<string>() + const entries: ParsedSourceUrl[] = [] + for (const line of input.split(/\r?\n/)) { + const url = line.trim() + if (!url || seen.has(url)) continue + seen.add(url) + entries.push({ + url, + detectedAdapterId: detectAdapterFromUrl(url), + }) + } + return entries +} + // ── Adapter selection guard ──────────────────────────────────────────────────── /** @@ -81,6 +127,54 @@ export function canAdvanceStep1( return !!selectedAdapterId && !!selectedKnowledgeGraphId } +export interface Step1ValidationResult { + valid: boolean + sourceUrlError: string + providerError: string +} + +export function validateStep1(opts: { + selectedKnowledgeGraphId: string + sourceUrl: string + detectedAdapterId: DetectedAdapterId +}): Step1ValidationResult { + const result: Step1ValidationResult = { + valid: true, + sourceUrlError: '', + providerError: '', + } + + if (!opts.selectedKnowledgeGraphId.trim()) { + result.providerError = 'Select a knowledge graph to continue.' + result.valid = false + } + + if (!opts.sourceUrl.trim()) { + result.sourceUrlError = 'Source URL is required.' + result.valid = false + return result + } + + try { + // URL constructor validates format. + new URL(opts.sourceUrl.trim()) + } catch { + result.sourceUrlError = 'Enter a valid source URL.' + result.valid = false + return result + } + + if (opts.detectedAdapterId === 'unknown') { + result.providerError = 'Could not detect provider from this URL.' + result.valid = false + } else if (opts.detectedAdapterId !== 'github') { + result.providerError = `${opts.detectedAdapterId[0]!.toUpperCase()}${opts.detectedAdapterId.slice(1)} support is coming soon.` + result.valid = false + } + + return result +} + // ── Name inference ───────────────────────────────────────────────────────────── /** @@ -97,9 +191,20 @@ export function canAdvanceStep1( * `'not-a-url'` → `null` */ export function inferNameFromRepoUrl(url: string): string | null { - const match = url.trim().match(/github\.com\/[^/]+\/([^/]+?)(?:\.git)?\/?$/) - if (!match || !match[1]) return null - return match[1] + if (!url.trim()) return null + try { + const parsed = new URL(url.trim()) + const parts = parsed.pathname.split('/').filter(Boolean) + // github/gitlab repositories: /owner/repo + if (parts.length >= 2) { + return parts[1]!.replace(/\.git$/, '') + } + // fallback to the last segment when available + const fallback = parts[parts.length - 1] + return fallback ? fallback.replace(/\.git$/, '') : null + } catch { + return null + } } // ── Step 2 validation ────────────────────────────────────────────────────────── From 5cae3a967c72a2b23542af838acc74234df109fd Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 22 May 2026 18:44:17 -0400 Subject: [PATCH 53/80] fix(data-sources): modernize KG selector styling in add-source dialog Replace the native Knowledge Graph dropdown in the Add Data Source popup with the shared Select UI component so contrast, hover states, and overall visual style match the rest of the app. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/dev-ui/app/pages/data-sources/index.vue | 22 ++++++++++++++++----- src/dev-ui/app/tests/data-sources.test.ts | 7 +++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index 75ab47727..537e702ea 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -40,6 +40,13 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import SyncPhaseIndicator from '@/components/graph/SyncPhaseIndicator.vue' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { CopyableText } from '@/components/ui/copyable-text' @@ -1412,14 +1419,19 @@ async function handleDeleteDs() { <!-- Knowledge graph selection --> <div class="space-y-1.5"> <Label>Knowledge Graph <span class="text-destructive">*</span></Label> - <select + <Select v-if="!loadingKgs && knowledgeGraphs.length > 0" v-model="selectedKnowledgeGraphId" - class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > - <option value="">Select a knowledge graph...</option> - <option v-for="kg in knowledgeGraphs" :key="kg.id" :value="kg.id">{{ kg.name }}</option> - </select> + <SelectTrigger> + <SelectValue placeholder="Select a knowledge graph..." /> + </SelectTrigger> + <SelectContent> + <SelectItem v-for="kg in knowledgeGraphs" :key="kg.id" :value="kg.id"> + {{ kg.name }} + </SelectItem> + </SelectContent> + </Select> <div v-else-if="loadingKgs" class="flex items-center gap-2 text-sm text-muted-foreground"> <Loader2 class="size-4 animate-spin" /> Loading knowledge graphs... </div> diff --git a/src/dev-ui/app/tests/data-sources.test.ts b/src/dev-ui/app/tests/data-sources.test.ts index 340fb2828..be978914a 100644 --- a/src/dev-ui/app/tests/data-sources.test.ts +++ b/src/dev-ui/app/tests/data-sources.test.ts @@ -3165,6 +3165,13 @@ describe('Data-sources-focused layout - structural verification', () => { expect(source).toContain('onboarding is coming soon, sorry.') expect(source).toContain('Add to project') }) + + it('uses shadcn Select for knowledge graph dropdown styling consistency', () => { + expect(source).toContain('<Select v-model="selectedKnowledgeGraphId">') + expect(source).toContain('SelectTrigger') + expect(source).toContain('SelectContent') + expect(source).not.toContain('<select') + }) }) describe('Bulk onboarding partial-success behavior', () => { From bdeabf7c290827899f0155fbfb45d792bc269d48 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Tue, 26 May 2026 14:26:06 -0400 Subject: [PATCH 54/80] feat(ui): add KG-scoped data source onboarding flow Introduce k-extract-style full-page routes for connecting repositories from the knowledge graph manage workspace: wizard at /data-sources/new with post-create sequential sync, and an operations page when sources already exist. Closes #736. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../[kgId]/data-sources/index.vue | 621 ++++++++++++++++ .../[kgId]/data-sources/new.vue | 703 ++++++++++++++++++ .../pages/knowledge-graphs/[kgId]/manage.vue | 4 +- .../app/pages/knowledge-graphs/index.vue | 4 +- .../tests/kg-data-sources-navigation.test.ts | 39 + .../knowledge-graph-manage-workspace.test.ts | 20 +- src/dev-ui/app/tests/knowledge-graphs.test.ts | 19 +- .../app/tests/task-121-spec-alignment.test.ts | 38 +- .../app/utils/kgDataSourcesNavigation.ts | 39 + src/dev-ui/app/utils/kgDataSourcesSync.ts | 41 + src/dev-ui/app/utils/kgManageWorkspace.ts | 24 +- 11 files changed, 1500 insertions(+), 52 deletions(-) create mode 100644 src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue create mode 100644 src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue create mode 100644 src/dev-ui/app/tests/kg-data-sources-navigation.test.ts create mode 100644 src/dev-ui/app/utils/kgDataSourcesNavigation.ts create mode 100644 src/dev-ui/app/utils/kgDataSourcesSync.ts diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue new file mode 100644 index 000000000..614924aac --- /dev/null +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -0,0 +1,621 @@ +<script setup lang="ts"> +import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' +import { toast } from 'vue-sonner' +import { + Cable, + ChevronLeft, + Plus, + Loader2, + Trash2, + Settings, + RefreshCw, + ScrollText, + Building2, +} from 'lucide-vue-next' +import { + buildKgDataSourcesNewUrl, + buildKgManageUrl, + parseKgDataSourcesFocusQuery, +} from '@/utils/kgDataSourcesNavigation' +import { isMaintenanceReady } from '@/utils/kgManageWorkspace' +import { hasAnyActiveSync, type SyncRunStatus } from '@/utils/kgDataSourcesSync' +import SyncPhaseIndicator from '@/components/graph/SyncPhaseIndicator.vue' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { CopyableText } from '@/components/ui/copyable-text' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +interface SyncRun { + id: string + status: SyncRunStatus + started_at: string + completed_at: string | null + error: string | null +} + +interface DiffChangedFile { + path: string + status: string +} + +interface DataSourceDiffSummary { + total_changed_files: number + added_count: number + modified_count: number + removed_count: number + renamed_count: number + files_truncated: boolean + changed_files: DiffChangedFile[] +} + +interface DataSourceItem { + id: string + name: string + adapter_type: string + knowledge_graph_id: string + clone_head_commit?: string | null + last_extraction_baseline_commit?: string | null + tracked_branch_head_commit?: string | null + sync_runs?: SyncRun[] + diff_summary?: DataSourceDiffSummary | null +} + +const route = useRoute() +const kgId = computed(() => route.params.kgId as string) +const maintainFocus = computed(() => parseKgDataSourcesFocusQuery(route.query.focus) === 'maintain') + +const { hasTenant, tenantVersion } = useTenant() +const { apiFetch } = useApiClient() + +const kgName = ref('') +const dataSources = ref<DataSourceItem[]>([]) +const loading = ref(false) +const expandedDiffLists = ref<Record<string, boolean>>({}) +const refreshingCommitRefs = ref<Record<string, boolean>>({}) +const adoptingBaselines = ref<Record<string, boolean>>({}) + +const manageUrl = computed(() => buildKgManageUrl(kgId.value)) +const newSourceUrl = computed(() => buildKgDataSourcesNewUrl(kgId.value)) + +const visibleDataSources = computed(() => { + if (!maintainFocus.value) return dataSources.value + return dataSources.value.filter((ds) => isMaintenanceReady(ds)) +}) + +const pollInterval = ref<ReturnType<typeof setInterval> | null>(null) + +function stopPolling() { + if (pollInterval.value !== null) { + clearInterval(pollInterval.value) + pollInterval.value = null + } +} + +function startPolling() { + if (pollInterval.value !== null) return + pollInterval.value = setInterval(async () => { + await loadDataSources() + if (!hasAnyActiveSync(dataSources.value)) { + stopPolling() + } + }, 3000) +} + +async function loadKnowledgeGraph() { + try { + const result = await apiFetch<{ name: string }>( + `/management/knowledge-graphs/${kgId.value}`, + ) + kgName.value = result.name ?? kgId.value + } catch { + kgName.value = kgId.value + } +} + +async function loadDataSources() { + if (!hasTenant.value) return + loading.value = true + try { + const sources = await apiFetch<DataSourceItem[]>( + `/management/knowledge-graphs/${kgId.value}/data-sources`, + ) + for (const ds of sources) { + try { + ds.sync_runs = await apiFetch<SyncRun[]>( + `/management/data-sources/${ds.id}/sync-runs`, + ) + } catch { + ds.sync_runs = [] + } + try { + ds.diff_summary = await apiFetch<DataSourceDiffSummary>( + `/management/data-sources/${ds.id}/diff-summary`, + ) + } catch { + ds.diff_summary = null + } + } + dataSources.value = sources + } catch { + dataSources.value = [] + } finally { + loading.value = false + } +} + +async function ensureEntryRoute() { + await loadDataSources() + if (dataSources.value.length === 0) { + await navigateTo(newSourceUrl.value, { replace: true }) + return + } + if (hasAnyActiveSync(dataSources.value)) { + startPolling() + } +} + +function isDiffExpanded(dsId: string): boolean { + return expandedDiffLists.value[dsId] === true +} + +function toggleDiffExpanded(dsId: string) { + expandedDiffLists.value[dsId] = !isDiffExpanded(dsId) +} + +async function triggerSync(dsId: string) { + try { + await apiFetch(`/management/data-sources/${dsId}/sync`, { method: 'POST' }) + toast.success('Sync triggered') + await loadDataSources() + if (hasAnyActiveSync(dataSources.value)) startPolling() + } catch { + toast.error('Failed to trigger sync') + } +} + +async function refreshCommitRefs(dsId: string) { + refreshingCommitRefs.value[dsId] = true + try { + await apiFetch(`/management/data-sources/${dsId}/commit-refs/refresh`, { method: 'POST' }) + toast.success('Commit references refreshed') + await loadDataSources() + } catch { + toast.error('Failed to refresh commit references') + } finally { + refreshingCommitRefs.value[dsId] = false + } +} + +async function adoptTrackedHeadBaseline(dsId: string) { + adoptingBaselines.value[dsId] = true + try { + await apiFetch(`/management/data-sources/${dsId}/commit-refs/adopt-tracked-head`, { + method: 'POST', + }) + toast.success('Baseline updated to tracked head') + await loadDataSources() + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to update baseline' + toast.error('Failed to update baseline', { description: msg }) + } finally { + adoptingBaselines.value[dsId] = false + } +} + +// Edit config sheet +const editConfigOpen = ref(false) +const editConfigDs = ref<DataSourceItem | null>(null) +const editConfigName = ref('') +const editConfigToken = ref('') +const editConfigNameError = ref('') +const savingConfig = ref(false) + +function openEditConfig(ds: DataSourceItem) { + editConfigDs.value = ds + editConfigName.value = ds.name + editConfigToken.value = '' + editConfigNameError.value = '' + editConfigOpen.value = true +} + +async function handleEditConfig() { + if (!editConfigName.value.trim()) { + editConfigNameError.value = 'Data source name is required' + return + } + savingConfig.value = true + try { + const body: Record<string, unknown> = { name: editConfigName.value.trim() } + if (editConfigToken.value.trim()) { + body.credentials = { access_token: editConfigToken.value.trim() } + } + await apiFetch(`/management/data-sources/${editConfigDs.value!.id}`, { + method: 'PATCH', + body, + }) + toast.success('Data source updated') + editConfigOpen.value = false + await loadDataSources() + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to update' + toast.error('Failed to update data source', { description: msg }) + } finally { + savingConfig.value = false + } +} + +// Delete +const deleteDsOpen = ref(false) +const deletingDs = ref<DataSourceItem | null>(null) +const deletingDsFlag = ref(false) + +function openDeleteDs(ds: DataSourceItem) { + deletingDs.value = ds + deleteDsOpen.value = true +} + +async function handleDeleteDs() { + if (!deletingDs.value) return + deletingDsFlag.value = true + try { + await apiFetch(`/management/data-sources/${deletingDs.value.id}`, { method: 'DELETE' }) + toast.success(`Data source "${deletingDs.value.name}" deleted`) + deleteDsOpen.value = false + await loadDataSources() + if (dataSources.value.length === 0) { + await navigateTo(newSourceUrl.value, { replace: true }) + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to delete' + toast.error('Failed to delete data source', { description: msg }) + } finally { + deletingDsFlag.value = false + deletingDs.value = null + } +} + +// Sync logs sheet +const logSheetOpen = ref(false) +const selectedLogRunId = ref<string | null>(null) +const runLogs = ref<string[]>([]) +const logsLoading = ref(false) +const logsError = ref<string | null>(null) + +async function viewLogs(ds: DataSourceItem, run: SyncRun) { + selectedLogRunId.value = run.id + runLogs.value = [] + logsError.value = null + logSheetOpen.value = true + logsLoading.value = true + try { + const result = await apiFetch<{ logs: string[] }>( + `/management/data-sources/${ds.id}/sync-runs/${run.id}/logs`, + ) + runLogs.value = result.logs ?? [] + } catch (err) { + logsError.value = err instanceof Error ? err.message : 'Failed to load logs' + } finally { + logsLoading.value = false + } +} + +onMounted(async () => { + if (!hasTenant.value) return + await loadKnowledgeGraph() + await ensureEntryRoute() + if (maintainFocus.value) { + await nextTick() + document.getElementById('maintain-section')?.scrollIntoView({ behavior: 'smooth' }) + } +}) + +onUnmounted(() => stopPolling()) + +watch(tenantVersion, async () => { + dataSources.value = [] + await loadKnowledgeGraph() + await ensureEntryRoute() +}) +</script> + +<template> + <div class="mx-auto max-w-5xl space-y-6"> + <div class="flex flex-wrap items-center justify-between gap-3"> + <NuxtLink + :to="manageUrl" + class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" + > + <ChevronLeft class="mr-1 size-4" /> + Back to workspace overview + </NuxtLink> + <Button :disabled="!hasTenant" @click="navigateTo(newSourceUrl)"> + <Plus class="mr-2 size-4" /> + Add data source + </Button> + </div> + + <div class="flex items-center gap-3"> + <div class="rounded-lg bg-primary/10 p-2"> + <Cable class="size-5 text-primary" /> + </div> + <div> + <h1 class="text-2xl font-semibold tracking-tight">Data Sources</h1> + <p class="text-sm text-muted-foreground"> + <template v-if="kgName">{{ kgName }} — </template> + Manage connected repositories, sync runs, and commit tracking. + </p> + </div> + </div> + + <Separator /> + + <div v-if="!hasTenant" class="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground"> + <Building2 class="size-10" /> + <p class="font-medium">No tenant selected</p> + </div> + + <div v-else-if="loading" class="flex justify-center py-16"> + <Loader2 class="size-8 animate-spin text-muted-foreground" /> + </div> + + <template v-else> + <Card v-if="maintainFocus"> + <CardHeader class="pb-2"> + <CardTitle class="text-sm">Maintenance focus</CardTitle> + <CardDescription class="text-xs"> + Showing sources with new commits since the last extraction baseline. + </CardDescription> + </CardHeader> + </Card> + + <div + v-if="visibleDataSources.length === 0" + class="flex flex-col items-center gap-4 py-16 text-center" + > + <p class="text-sm text-muted-foreground"> + <template v-if="maintainFocus"> + No sources need maintenance right now. + </template> + <template v-else> + No data sources connected. + </template> + </p> + <Button v-if="!maintainFocus" @click="navigateTo(newSourceUrl)"> + <Plus class="mr-2 size-4" /> + Add your first data source + </Button> + </div> + + <div v-else id="maintain-section" class="space-y-3"> + <div + v-for="ds in visibleDataSources" + :key="ds.id" + class="rounded-lg border bg-card" + :class="isMaintenanceReady(ds) ? 'border-amber-300/60' : ''" + > + <div class="flex flex-wrap items-center justify-between gap-3 p-4"> + <div class="flex items-center gap-3"> + <div class="rounded-md bg-muted p-2"> + <Cable class="size-4 text-muted-foreground" /> + </div> + <div> + <p class="text-sm font-medium">{{ ds.name }}</p> + <p class="text-xs text-muted-foreground">{{ ds.adapter_type }}</p> + <CopyableText :text="ds.id" label="Data source ID copied" class="mt-0.5" /> + </div> + </div> + <div class="flex flex-wrap items-center gap-2"> + <SyncPhaseIndicator + v-if="ds.sync_runs?.[0]" + :status="ds.sync_runs[0].status" + /> + <Badge v-else variant="secondary" class="text-[10px]">Idle</Badge> + <Button size="sm" variant="outline" @click="openEditConfig(ds)"> + <Settings class="mr-1.5 size-3.5" /> + Edit Config + </Button> + <Button + size="sm" + variant="outline" + class="text-destructive hover:bg-destructive/10" + @click="openDeleteDs(ds)" + > + <Trash2 class="mr-1.5 size-3.5" /> + Delete + </Button> + <Button size="sm" variant="outline" @click="triggerSync(ds.id)"> + Sync Now + </Button> + </div> + </div> + + <div class="border-t px-4 py-3"> + <p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> + Commit Status + </p> + <div class="grid gap-2 sm:grid-cols-3"> + <div class="rounded-md border bg-muted/20 p-2"> + <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Local clone commit</p> + <p class="mt-1 break-all font-mono text-xs">{{ ds.clone_head_commit ?? '—' }}</p> + </div> + <div class="rounded-md border bg-muted/20 p-2"> + <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Last extraction baseline</p> + <p class="mt-1 break-all font-mono text-xs">{{ ds.last_extraction_baseline_commit ?? '—' }}</p> + </div> + <div class="rounded-md border bg-muted/20 p-2"> + <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Tracked branch head</p> + <p class="mt-1 break-all font-mono text-xs">{{ ds.tracked_branch_head_commit ?? '—' }}</p> + </div> + </div> + <div class="mt-2 flex flex-wrap gap-2"> + <Button + size="sm" + variant="outline" + class="h-7 text-[10px]" + :disabled="refreshingCommitRefs[ds.id] === true" + @click="refreshCommitRefs(ds.id)" + > + <RefreshCw + class="mr-1 size-3" + :class="refreshingCommitRefs[ds.id] ? 'animate-spin' : ''" + /> + Refresh commits + </Button> + <Button + size="sm" + variant="outline" + class="h-7 text-[10px]" + :disabled="adoptingBaselines[ds.id] === true || !isMaintenanceReady(ds)" + @click="adoptTrackedHeadBaseline(ds.id)" + > + Adopt tracked head as baseline + </Button> + </div> + + <div + v-if="ds.diff_summary" + class="mt-3 rounded-md border p-2" + :class="isMaintenanceReady(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" + > + <div class="flex items-center justify-between gap-2 text-xs"> + <span> + <span class="font-medium">{{ ds.diff_summary.total_changed_files }}</span> + changed files + </span> + <Badge + :variant="isMaintenanceReady(ds) ? 'default' : 'secondary'" + class="text-[10px]" + > + {{ isMaintenanceReady(ds) ? 'New commits available' : 'Up to date' }} + </Badge> + </div> + <Button + v-if="ds.diff_summary.changed_files.length > 0" + size="sm" + variant="ghost" + class="mt-2 h-6 px-2 text-[10px]" + @click="toggleDiffExpanded(ds.id)" + > + {{ isDiffExpanded(ds.id) ? 'Hide changed files' : 'Show changed files' }} + </Button> + <div + v-if="isDiffExpanded(ds.id)" + class="mt-2 max-h-48 space-y-1 overflow-y-auto rounded-md border bg-background/80 p-2" + > + <div + v-for="file in ds.diff_summary.changed_files" + :key="`${file.status}:${file.path}`" + class="flex justify-between gap-2 text-[11px]" + > + <span class="break-all font-mono">{{ file.path }}</span> + <Badge variant="outline" class="h-5 text-[10px] uppercase">{{ file.status }}</Badge> + </div> + </div> + </div> + </div> + + <div v-if="ds.sync_runs?.length" class="border-t px-4 py-3"> + <p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> + Sync History + </p> + <div class="space-y-1"> + <div + v-for="run in ds.sync_runs" + :key="run.id" + class="flex items-center gap-2 text-xs text-muted-foreground" + > + <SyncPhaseIndicator :status="run.status" /> + <span>{{ new Date(run.started_at).toLocaleString() }}</span> + <span v-if="run.error" class="text-destructive">{{ run.error }}</span> + <Button + size="sm" + variant="ghost" + class="ml-auto h-6 px-2 text-[10px]" + @click="viewLogs(ds, run)" + > + <ScrollText class="mr-1 size-3" /> + View Logs + </Button> + </div> + </div> + </div> + </div> + </div> + </template> + + <Sheet v-model:open="editConfigOpen"> + <SheetContent> + <SheetHeader> + <SheetTitle>Edit configuration</SheetTitle> + <SheetDescription>Update name or rotate credentials.</SheetDescription> + </SheetHeader> + <div class="mt-4 space-y-4"> + <div class="space-y-1.5"> + <Label>Name</Label> + <Input v-model="editConfigName" /> + <p v-if="editConfigNameError" class="text-xs text-destructive">{{ editConfigNameError }}</p> + </div> + <div class="space-y-1.5"> + <Label>New access token (optional)</Label> + <Input v-model="editConfigToken" type="password" autocomplete="off" /> + </div> + <Button :disabled="savingConfig" @click="handleEditConfig"> + <Loader2 v-if="savingConfig" class="mr-2 size-4 animate-spin" /> + Save + </Button> + </div> + </SheetContent> + </Sheet> + + <Sheet v-model:open="logSheetOpen"> + <SheetContent class="sm:max-w-xl"> + <SheetHeader> + <SheetTitle>Sync logs</SheetTitle> + <SheetDescription>Run {{ selectedLogRunId }}</SheetDescription> + </SheetHeader> + <div class="mt-4 max-h-[70vh] overflow-y-auto font-mono text-xs"> + <Loader2 v-if="logsLoading" class="mx-auto size-6 animate-spin" /> + <p v-else-if="logsError" class="text-destructive">{{ logsError }}</p> + <pre v-else class="whitespace-pre-wrap">{{ runLogs.join('\n') || 'No log lines.' }}</pre> + </div> + </SheetContent> + </Sheet> + + <AlertDialog v-model:open="deleteDsOpen"> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete data source?</AlertDialogTitle> + <AlertDialogDescription> + This permanently deletes "{{ deletingDs?.name }}" and its sync history. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction :disabled="deletingDsFlag" @click="handleDeleteDs"> + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> +</template> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue new file mode 100644 index 000000000..98ec4254d --- /dev/null +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue @@ -0,0 +1,703 @@ +<script setup lang="ts"> +import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' +import { toast } from 'vue-sonner' +import { + ChevronLeft, + GitBranch, + Plus, + Trash2, + Loader2, + Check, + X, + Cable, + ArrowRight, + Settings2, + LayoutDashboard, +} from 'lucide-vue-next' +import { + inferNameFromRepoUrl, + validateStep1, + validateStep2, + buildDataSourceCreationUrl, + buildDataSourceCreationBody, + detectAdapterFromUrl, +} from '@/utils/dataSourceWizard' +import type { DetectedAdapterId } from '@/utils/dataSourceWizard' +import { + buildKgDataSourcesUrl, + buildKgManageUrl, +} from '@/utils/kgDataSourcesNavigation' +import { + isActiveSyncStatus, + isSyncTerminal, + latestSyncRun, + type SyncRunStatus, +} from '@/utils/kgDataSourcesSync' +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import SyncPhaseIndicator from '@/components/graph/SyncPhaseIndicator.vue' + +type FlowPhase = 'urls' | 'configure' | 'sync' | 'stats' + +interface PendingSourceDraft { + id: string + url: string + detectedAdapterId: DetectedAdapterId + name: string + branch: string + nameError: string + urlError: string + branchError: string +} + +interface SourceUrlInputRow { + id: string + url: string +} + +interface CreatedSourceRow { + id: string + name: string + url: string + branch: string + syncStatus: SyncRunStatus | 'idle' | 'queued' + syncError: string | null + token_usage_total: number | null + cost_total_usd: number | null +} + +const route = useRoute() +const kgId = computed(() => route.params.kgId as string) + +const { hasTenant, tenantVersion } = useTenant() +const { apiFetch } = useApiClient() + +const kgName = ref('') +const loadingKg = ref(false) + +const flowPhase = ref<FlowPhase>('urls') +const sourceUrlInputs = ref<SourceUrlInputRow[]>([{ id: 'source-1', url: '' }]) +const sourceUrlError = ref('') +const providerError = ref('') +const pendingSources = ref<PendingSourceDraft[]>([]) +const detectingSourceDetails = ref(false) +const connToken = ref('') +const creating = ref(false) + +const createdSources = ref<CreatedSourceRow[]>([]) +const syncRunActive = ref(false) +const syncCompletedInRun = ref(0) +const syncRunTotal = ref(0) +const syncActiveName = ref<string | null>(null) +const syncStepLabel = ref('') +const readyForStats = ref(false) + +const wizardSectionRef = ref<HTMLElement | null>(null) + +const manageUrl = computed(() => buildKgManageUrl(kgId.value)) +const operationsUrl = computed(() => buildKgDataSourcesUrl(kgId.value)) + +const validUrlRows = computed(() => + sourceUrlInputs.value + .map((row) => row.url.trim()) + .filter((url) => url.length > 0), +) + +const syncProgressPercent = computed(() => { + if (syncRunTotal.value === 0) return 0 + return Math.round((syncCompletedInRun.value / syncRunTotal.value) * 100) +}) + +const completedSyncCount = computed(() => + createdSources.value.filter((s) => s.syncStatus === 'completed').length, +) + +const totalTokenUsage = computed(() => + createdSources.value.reduce((sum, s) => sum + (s.token_usage_total ?? 0), 0), +) + +const totalSyncCost = computed(() => + createdSources.value.reduce((sum, s) => sum + (s.cost_total_usd ?? 0), 0), +) + +function addUrlField() { + sourceUrlInputs.value.push({ + id: `source-${Date.now()}-${sourceUrlInputs.value.length + 1}`, + url: '', + }) +} + +function removeUrlField(id: string) { + if (sourceUrlInputs.value.length === 1) { + sourceUrlInputs.value[0]!.url = '' + return + } + sourceUrlInputs.value = sourceUrlInputs.value.filter((row) => row.id !== id) +} + +async function loadKnowledgeGraph() { + loadingKg.value = true + try { + const result = await apiFetch<{ name: string }>( + `/management/knowledge-graphs/${kgId.value}`, + ) + kgName.value = result.name ?? kgId.value + } catch { + kgName.value = kgId.value + } finally { + loadingKg.value = false + } +} + +async function detectGithubSourceDetails(entry: PendingSourceDraft) { + if (entry.detectedAdapterId !== 'github') return + try { + const parsed = new URL(entry.url) + const [owner, repoRaw] = parsed.pathname.split('/').filter(Boolean) + const repo = repoRaw?.replace(/\.git$/, '') + if (!owner || !repo) return + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`) + if (!response.ok) return + const payload = await response.json() as { default_branch?: string; name?: string } + if (!entry.branch.trim() && payload.default_branch) { + entry.branch = payload.default_branch + } + if (!entry.name.trim() && payload.name) { + entry.name = payload.name + } + } catch { + // Best effort only. + } +} + +function proceedToConfigure() { + const seen = new Set<string>() + const parsedEntries: Array<{ url: string; detectedAdapterId: DetectedAdapterId }> = [] + for (const row of sourceUrlInputs.value) { + const url = row.url.trim() + if (!url || seen.has(url)) continue + seen.add(url) + parsedEntries.push({ url, detectedAdapterId: detectAdapterFromUrl(url) }) + } + + if (parsedEntries.length === 0) { + sourceUrlError.value = 'Provide at least one source URL.' + return + } + + const drafts: PendingSourceDraft[] = parsedEntries.map((entry, index) => ({ + id: `src-${index}-${entry.url}`, + url: entry.url, + detectedAdapterId: entry.detectedAdapterId, + name: inferNameFromRepoUrl(entry.url) ?? '', + branch: '', + nameError: '', + urlError: '', + branchError: '', + })) + + let hasError = false + const providerIssues: string[] = [] + for (const entry of drafts) { + const validation = validateStep1({ + selectedKnowledgeGraphId: kgId.value, + sourceUrl: entry.url, + detectedAdapterId: entry.detectedAdapterId, + }) + entry.urlError = validation.sourceUrlError + if (validation.providerError) { + providerIssues.push(`${entry.url}: ${validation.providerError}`) + } + if (!validation.valid) hasError = true + } + + pendingSources.value = drafts + sourceUrlError.value = hasError && drafts.some((d) => !!d.urlError) + ? 'One or more URLs are invalid.' + : '' + providerError.value = providerIssues.join(' | ') + if (hasError) return + + detectingSourceDetails.value = true + Promise.all(drafts.map((d) => detectGithubSourceDetails(d))) + .finally(() => { + detectingSourceDetails.value = false + flowPhase.value = 'configure' + }) +} + +async function createDataSources() { + let hasError = false + for (const entry of pendingSources.value) { + const validation = validateStep2({ + connName: entry.name, + connRepoUrl: entry.url, + }) + entry.nameError = validation.connNameError + entry.urlError = validation.connRepoUrlError + entry.branchError = !entry.branch.trim() ? 'Tracked branch is required.' : '' + if (!validation.valid || entry.branchError) hasError = true + } + if (hasError) return + + creating.value = true + const rows: CreatedSourceRow[] = [] + const failed: string[] = [] + + try { + for (const entry of pendingSources.value) { + try { + const created = await apiFetch<{ id: string; name: string }>( + buildDataSourceCreationUrl(kgId.value), + { + method: 'POST', + body: buildDataSourceCreationBody({ + name: entry.name, + adapter_type: 'github', + connection_config: { + repo_url: entry.url, + branch: entry.branch, + }, + credentials: connToken.value ? { access_token: connToken.value } : undefined, + }), + }, + ) + rows.push({ + id: created.id, + name: created.name, + url: entry.url, + branch: entry.branch, + syncStatus: 'idle', + syncError: null, + token_usage_total: null, + cost_total_usd: null, + }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to connect source' + failed.push(`${entry.url}: ${msg}`) + } + } + + if (rows.length === 0) { + toast.error('Connection failed', { description: failed[0] ?? 'No sources were created.' }) + return + } + + connToken.value = '' + createdSources.value = rows + flowPhase.value = 'sync' + readyForStats.value = false + + toast.success('Data sources connected', { + description: `${rows.length} source(s) ready for initial sync.`, + }) + + await nextTick() + wizardSectionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + + if (failed.length > 0) { + toast.warning('Some sources were not connected', { description: failed.join(' | ') }) + } + } finally { + creating.value = false + } +} + +async function refreshSourceSyncStatus(row: CreatedSourceRow) { + const runs = await apiFetch<Array<{ + status: SyncRunStatus + error: string | null + token_usage_total?: number | null + cost_total_usd?: number | null + }>>(`/management/data-sources/${row.id}/sync-runs`) + const latest = latestSyncRun(runs) + if (latest) { + row.syncStatus = latest.status + row.syncError = latest.error + row.token_usage_total = latest.token_usage_total ?? null + row.cost_total_usd = latest.cost_total_usd ?? null + } +} + +async function pollUntilTerminal(row: CreatedSourceRow, timeoutMs = 600_000) { + const started = Date.now() + while (Date.now() - started < timeoutMs) { + await refreshSourceSyncStatus(row) + if (isSyncTerminal(row.syncStatus as SyncRunStatus)) return + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + row.syncStatus = 'failed' + row.syncError = 'Sync timed out' +} + +async function runSequentialSync() { + const queue = createdSources.value.filter( + (s) => s.syncStatus === 'idle' || s.syncStatus === 'failed' || s.syncStatus === 'queued', + ) + if (queue.length === 0) { + toast.error('No sources need syncing') + return + } + + syncRunActive.value = true + syncRunTotal.value = queue.length + syncCompletedInRun.value = 0 + readyForStats.value = false + + try { + for (let i = 0; i < queue.length; i++) { + const target = queue[i]! + syncStepLabel.value = `${i + 1} / ${queue.length}` + syncActiveName.value = target.name + target.syncStatus = 'pending' + target.syncError = null + + try { + await apiFetch(`/management/data-sources/${target.id}/sync`, { method: 'POST' }) + await pollUntilTerminal(target) + if (target.syncStatus === 'failed') { + toast.error(`Sync failed: ${target.name}`, { + description: target.syncError ?? undefined, + }) + } + } catch (err: unknown) { + target.syncStatus = 'failed' + target.syncError = err instanceof Error ? err.message : 'Sync failed' + toast.error(`Sync failed: ${target.name}`, { description: target.syncError }) + } + + syncCompletedInRun.value = i + 1 + } + + const allCompleted = createdSources.value.every((s) => s.syncStatus === 'completed') + readyForStats.value = allCompleted + + if (allCompleted) { + flowPhase.value = 'stats' + await nextTick() + wizardSectionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + toast.success('Initial sync complete', { + description: 'Review results below, then open data sources to continue.', + }) + } else { + toast('Sync finished with issues', { + description: 'Fix failed sources from the data sources page or retry sync.', + }) + } + } finally { + syncRunActive.value = false + syncActiveName.value = null + syncStepLabel.value = '' + } +} + +function getSyncBadge(status: CreatedSourceRow['syncStatus']) { + switch (status) { + case 'completed': + return { variant: 'default' as const, label: 'Completed', icon: Check } + case 'failed': + return { variant: 'destructive' as const, label: 'Failed', icon: X } + case 'pending': + case 'ingesting': + case 'ai_extracting': + case 'applying': + return { variant: 'secondary' as const, label: 'Syncing…', icon: Loader2 } + default: + return { variant: 'outline' as const, label: 'Ready', icon: null } + } +} + +onMounted(async () => { + if (!hasTenant.value) return + await loadKnowledgeGraph() +}) + +watch(tenantVersion, () => { + loadKnowledgeGraph() +}) + +onUnmounted(() => { + syncRunActive.value = false +}) +</script> + +<template> + <div class="mx-auto max-w-4xl space-y-6"> + <NuxtLink + :to="manageUrl" + class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" + > + <ChevronLeft class="mr-1 size-4" /> + Back to workspace overview + </NuxtLink> + + <div v-if="!hasTenant" class="py-16 text-center text-muted-foreground"> + Select a tenant from the sidebar to connect data sources. + </div> + + <template v-else> + <!-- URLs --> + <Card v-if="flowPhase === 'urls'"> + <CardHeader> + <div class="flex items-center gap-2"> + <GitBranch class="size-5 text-primary" /> + <CardTitle>Add data sources</CardTitle> + </div> + <CardDescription> + Connect Git repositories to + <Badge v-if="kgName" variant="outline" class="mx-1">{{ kgName }}</Badge> + <span v-else-if="loadingKg" class="text-muted-foreground">loading…</span>. + You will confirm branch and credentials next, then run an initial sync. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4"> + <div class="space-y-3"> + <div + v-for="row in sourceUrlInputs" + :key="row.id" + class="flex items-center gap-2" + > + <Input + v-model="row.url" + type="text" + placeholder="https://github.com/org/repo" + class="flex-1 font-mono text-sm" + /> + <Button + variant="ghost" + size="icon" + :disabled="sourceUrlInputs.length === 1 && !sourceUrlInputs[0]?.url" + @click="removeUrlField(row.id)" + > + <Trash2 class="size-4" /> + </Button> + </div> + </div> + <Button variant="outline" size="sm" type="button" @click="addUrlField"> + <Plus class="mr-2 size-4" /> + Add another URL + </Button> + <p v-if="sourceUrlError" class="text-sm text-destructive">{{ sourceUrlError }}</p> + <p v-if="providerError" class="text-sm text-destructive">{{ providerError }}</p> + <p class="text-xs text-muted-foreground"> + GitHub repositories are supported today. You can add more sources later from the + data sources page. + </p> + </CardContent> + <CardFooter> + <Button + type="button" + :disabled="validUrlRows.length === 0 || detectingSourceDetails" + @click="proceedToConfigure" + > + <Loader2 v-if="detectingSourceDetails" class="mr-2 size-4 animate-spin" /> + Continue + <ArrowRight v-else class="ml-2 size-4" /> + </Button> + </CardFooter> + </Card> + + <!-- Configure before create --> + <Card v-if="flowPhase === 'configure'"> + <CardHeader> + <div class="flex items-center gap-2"> + <Cable class="size-5 text-primary" /> + <CardTitle>Configure each repository</CardTitle> + </div> + <CardDescription> + Review names and tracked branches. Use one access token for all private repos if needed. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4"> + <div + v-for="entry in pendingSources" + :key="entry.id" + class="rounded-lg border p-4 space-y-3" + > + <p class="truncate font-mono text-xs text-muted-foreground">{{ entry.url }}</p> + <div class="grid gap-3 sm:grid-cols-2"> + <div class="space-y-1.5"> + <Label>Name</Label> + <Input v-model="entry.name" /> + <p v-if="entry.nameError" class="text-xs text-destructive">{{ entry.nameError }}</p> + </div> + <div class="space-y-1.5"> + <Label>Tracked branch</Label> + <Input v-model="entry.branch" placeholder="main" /> + <p v-if="entry.branchError" class="text-xs text-destructive">{{ entry.branchError }}</p> + </div> + </div> + <p v-if="entry.urlError" class="text-xs text-destructive">{{ entry.urlError }}</p> + </div> + <div class="space-y-1.5"> + <Label>GitHub access token (optional)</Label> + <Input v-model="connToken" type="password" placeholder="ghp_…" autocomplete="off" /> + <p class="text-xs text-muted-foreground"> + Required for private repositories. Applied to all sources in this batch. + </p> + </div> + </CardContent> + <CardFooter class="flex justify-between"> + <Button variant="outline" type="button" @click="flowPhase = 'urls'">Back</Button> + <Button type="button" :disabled="creating" @click="createDataSources"> + <Loader2 v-if="creating" class="mr-2 size-4 animate-spin" /> + <Check v-else class="mr-2 size-4" /> + Connect data sources and sync + </Button> + </CardFooter> + </Card> + + <!-- Sync + stats --> + <div + v-if="flowPhase === 'sync' || flowPhase === 'stats'" + ref="wizardSectionRef" + class="space-y-6" + > + <Card> + <CardHeader> + <div class="flex flex-wrap items-center gap-2"> + <Badge variant="default">{{ kgName }}</Badge> + <span class="text-sm text-muted-foreground">sources connected</span> + </div> + <CardTitle class="text-base">Initial sync</CardTitle> + <CardDescription> + Run ingestion and extraction for each source. Sources sync one at a time so you can + follow progress. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4"> + <div class="flex flex-wrap gap-2"> + <Button + type="button" + size="sm" + :disabled="syncRunActive || createdSources.length === 0" + @click="runSequentialSync" + > + <Loader2 v-if="syncRunActive" class="mr-2 size-4 animate-spin" /> + <GitBranch v-else class="mr-2 size-4" /> + Start initial sync + </Button> + </div> + + <div + v-if="syncRunActive" + class="space-y-2 rounded-lg border border-primary/30 bg-primary/5 p-4" + > + <div class="flex items-center justify-between text-sm"> + <span class="font-medium">Syncing {{ syncActiveName || '…' }}</span> + <span class="tabular-nums text-muted-foreground">{{ syncStepLabel }}</span> + </div> + <div class="h-2 overflow-hidden rounded-full bg-muted"> + <div + class="h-full rounded-full bg-primary transition-[width] duration-500 ease-out" + :style="{ width: `${syncProgressPercent}%` }" + /> + </div> + </div> + + <div class="space-y-4"> + <div + v-for="source in createdSources" + :key="source.id" + class="rounded-lg border p-4 transition-shadow" + :class="[ + source.syncStatus === 'failed' ? 'border-destructive/50 bg-destructive/5' : '', + syncActiveName === source.name ? 'ring-2 ring-primary/40' : '', + ]" + > + <div class="flex flex-wrap items-start justify-between gap-3"> + <div class="min-w-0 flex-1 space-y-1"> + <p class="font-medium">{{ source.name }}</p> + <p class="truncate font-mono text-xs text-muted-foreground">{{ source.url }}</p> + <p v-if="source.syncError" class="text-xs text-destructive">{{ source.syncError }}</p> + </div> + <div class="flex shrink-0 flex-col items-end gap-2"> + <SyncPhaseIndicator + v-if="isActiveSyncStatus(source.syncStatus as SyncRunStatus) || source.syncStatus === 'completed' || source.syncStatus === 'failed'" + :status="(source.syncStatus === 'idle' || source.syncStatus === 'queued') ? 'pending' : (source.syncStatus as SyncRunStatus)" + /> + <Badge v-else :variant="getSyncBadge(source.syncStatus).variant"> + {{ getSyncBadge(source.syncStatus).label }} + </Badge> + </div> + </div> + </div> + </div> + </CardContent> + </Card> + + <Card v-if="flowPhase === 'stats' && readyForStats" class="border-primary/30"> + <CardHeader> + <div class="flex items-center gap-2"> + <Settings2 class="size-5 text-primary" /> + <CardTitle class="text-base">Sync summary</CardTitle> + </div> + <CardDescription> + Initial sync finished for all sources. Open data sources to manage commits, ontology, + and maintenance. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4"> + <div class="overflow-x-auto rounded-md border"> + <table class="w-full min-w-[320px] text-sm"> + <thead> + <tr class="border-b bg-muted/50 text-left"> + <th class="px-3 py-2 font-medium">Data source</th> + <th class="px-3 py-2 text-right font-medium">Status</th> + <th class="px-3 py-2 text-right font-medium">Tokens</th> + <th class="px-3 py-2 text-right font-medium">Cost (USD)</th> + </tr> + </thead> + <tbody> + <tr + v-for="s in createdSources" + :key="s.id" + class="border-b border-border/60 last:border-0" + > + <td class="px-3 py-2 font-medium">{{ s.name }}</td> + <td class="px-3 py-2 text-right"> + <Badge variant="default" class="text-[10px]">Completed</Badge> + </td> + <td class="px-3 py-2 text-right tabular-nums"> + {{ s.token_usage_total?.toLocaleString() ?? '—' }} + </td> + <td class="px-3 py-2 text-right tabular-nums text-muted-foreground"> + {{ s.cost_total_usd != null ? s.cost_total_usd.toFixed(4) : '—' }} + </td> + </tr> + </tbody> + </table> + </div> + <p class="text-sm text-muted-foreground"> + <span class="font-medium text-foreground">{{ completedSyncCount }}</span> + source{{ completedSyncCount === 1 ? '' : 's' }} synced. + <template v-if="totalTokenUsage > 0"> + Total tokens: {{ totalTokenUsage.toLocaleString() }}. + </template> + <template v-if="totalSyncCost > 0"> + Estimated cost: ${{ totalSyncCost.toFixed(4) }}. + </template> + </p> + <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap"> + <Button as-child> + <NuxtLink :to="operationsUrl" class="inline-flex items-center gap-2"> + <Check class="size-4" /> + Open data sources + </NuxtLink> + </Button> + <Button as-child variant="outline"> + <NuxtLink :to="manageUrl" class="inline-flex items-center gap-2"> + <LayoutDashboard class="size-4" /> + Back to workspace overview + </NuxtLink> + </Button> + </div> + </CardContent> + </Card> + </div> + </template> + </div> +</template> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 017847d7e..8f175b9c2 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -487,7 +487,9 @@ async function applyInlineMutations() { } function openWorkspaceStep(stepId: WorkspaceStepId) { - navigateTo(resolveStepDestination(kgId.value, stepId)) + navigateTo(resolveStepDestination(kgId.value, stepId, { + dataSourceCount: dataSourceCount.value, + })) } function returnToWorkspaceOverview() { diff --git a/src/dev-ui/app/pages/knowledge-graphs/index.vue b/src/dev-ui/app/pages/knowledge-graphs/index.vue index 28c56c82b..d994f5021 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/index.vue @@ -155,14 +155,14 @@ async function handleCreate() { }, }, ) - // Pass the new KG id so the data-sources wizard pre-selects it automatically. + // Direct the user to kg-scoped data source onboarding for the new graph. // This satisfies: "AND the user is prompted to add their first data source" // with the wizard scoped to the newly created knowledge graph. toast.success(`Knowledge graph "${createName.value.trim()}" created`, { description: 'Next: connect a data source to start populating your graph.', action: { label: 'Add Data Source', - onClick: () => navigateTo(`/data-sources?kg_id=${result.id}`), + onClick: () => navigateTo(`/knowledge-graphs/${result.id}/data-sources/new`), }, duration: 8000, }) diff --git a/src/dev-ui/app/tests/kg-data-sources-navigation.test.ts b/src/dev-ui/app/tests/kg-data-sources-navigation.test.ts new file mode 100644 index 000000000..53134eea5 --- /dev/null +++ b/src/dev-ui/app/tests/kg-data-sources-navigation.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { + buildKgDataSourcesNewUrl, + buildKgDataSourcesUrl, + buildKgManageUrl, + parseKgDataSourcesFocusQuery, + resolveKgDataSourcesEntryUrl, +} from '../utils/kgDataSourcesNavigation' + +describe('kgDataSourcesNavigation', () => { + it('builds new onboarding URL', () => { + expect(buildKgDataSourcesNewUrl('kg-1')).toBe('/knowledge-graphs/kg-1/data-sources/new') + }) + + it('builds operations URL with optional maintain focus', () => { + expect(buildKgDataSourcesUrl('kg-1')).toBe('/knowledge-graphs/kg-1/data-sources') + expect(buildKgDataSourcesUrl('kg-1', { focus: 'maintain' })).toBe( + '/knowledge-graphs/kg-1/data-sources?focus=maintain', + ) + }) + + it('resolves entry URL from data source count', () => { + expect(resolveKgDataSourcesEntryUrl('kg-1', 0)).toBe( + '/knowledge-graphs/kg-1/data-sources/new', + ) + expect(resolveKgDataSourcesEntryUrl('kg-1', 2)).toBe( + '/knowledge-graphs/kg-1/data-sources', + ) + }) + + it('builds manage workspace return URL', () => { + expect(buildKgManageUrl('kg-abc')).toBe('/knowledge-graphs/kg-abc/manage') + }) + + it('parses maintain focus query', () => { + expect(parseKgDataSourcesFocusQuery('maintain')).toBe('maintain') + expect(parseKgDataSourcesFocusQuery('other')).toBeNull() + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 5c9c74b24..4a770be9b 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -295,13 +295,21 @@ describe('KG-MANAGE-004 - step card status semantics', () => { describe('KG-MANAGE-005 - graph-scoped data sources step', () => { it('keeps data-sources route utility for workspace cards but not graph-management redirects', () => { expect(manageWorkspaceVue).not.toContain('navigateTo(buildDataSourcesStepUrl(kgId))') - expect(buildDataSourcesStepUrl('kg-abc')).toBe('/data-sources?kg_id=kg-abc&from=manage') + expect(buildDataSourcesStepUrl('kg-abc', 0)).toBe('/knowledge-graphs/kg-abc/data-sources/new') + expect(buildDataSourcesStepUrl('kg-abc', 2)).toBe('/knowledge-graphs/kg-abc/data-sources') }) - it('data-sources page preserves manage return path without auto-opening wizard', () => { - expect(dataSourcesVue).toContain('from=manage') - expect(dataSourcesVue).toContain('scopedKnowledgeGraphId') - expect(dataSourcesVue).toContain('Back to workspace overview') + it('manage workspace passes data source count when opening data-sources step', () => { + expect(manageWorkspaceVue).toContain('dataSourceCount: dataSourceCount.value') + }) + + it('kg-scoped data sources pages preserve manage return path', () => { + const kgDataSourcesIndex = readFileSync( + resolve(__dirname, '../pages/knowledge-graphs/[kgId]/data-sources/index.vue'), + 'utf-8', + ) + expect(kgDataSourcesIndex).toContain('Back to workspace overview') + expect(kgDataSourcesIndex).toContain('buildKgManageUrl') }) }) @@ -309,7 +317,7 @@ describe('KG-MANAGE-015 - graph-scoped maintain step and round trip', () => { it('keeps maintain route utility for workspace cards but not graph-management redirects', () => { expect(manageWorkspaceVue).not.toContain('navigateTo(buildMaintainStepUrl(kgId))') expect(buildMaintainStepUrl('kg-abc')).toBe( - '/data-sources?kg_id=kg-abc&from=manage&focus=maintain', + '/knowledge-graphs/kg-abc/data-sources?focus=maintain', ) }) diff --git a/src/dev-ui/app/tests/knowledge-graphs.test.ts b/src/dev-ui/app/tests/knowledge-graphs.test.ts index 2c6e34ba8..a79d898df 100644 --- a/src/dev-ui/app/tests/knowledge-graphs.test.ts +++ b/src/dev-ui/app/tests/knowledge-graphs.test.ts @@ -1025,8 +1025,7 @@ describe('Knowledge Graph Creation — prompt to add first data source', () => { method: 'POST', body: { name: createName.value.trim() }, }) - // Include kg_id so data-sources wizard pre-selects the new knowledge graph. - actionOnClick = () => navigateTo(`/data-sources?kg_id=${result.id}`) + actionOnClick = () => navigateTo(`/knowledge-graphs/${result.id}/data-sources/new`) } finally { creating.value = false } @@ -1035,7 +1034,7 @@ describe('Knowledge Graph Creation — prompt to add first data source', () => { await handleCreate() expect(actionOnClick).toBeDefined() actionOnClick!() - expect(navigateTo).toHaveBeenCalledWith('/data-sources?kg_id=kg-new') + expect(navigateTo).toHaveBeenCalledWith('/knowledge-graphs/kg-new/data-sources/new') }) it('toast is not fired when KG creation fails (API error)', async () => { @@ -1097,7 +1096,7 @@ describe('Knowledge Graph Creation — KG-ID-scoped navigation (Task-101)', () = body: { name: createName.value.trim() }, }) // Capture the URL used in the action onClick - capturedUrl = `/data-sources?kg_id=${result.id}` + capturedUrl = `/knowledge-graphs/${result.id}/data-sources/new` navigateTo(capturedUrl) } finally { creating.value = false @@ -1105,17 +1104,17 @@ describe('Knowledge Graph Creation — KG-ID-scoped navigation (Task-101)', () = } await handleCreate() - expect(capturedUrl).toBe('/data-sources?kg_id=kg-abc-123') - expect(navigateTo).toHaveBeenCalledWith('/data-sources?kg_id=kg-abc-123') + expect(capturedUrl).toBe('/knowledge-graphs/kg-abc-123/data-sources/new') + expect(navigateTo).toHaveBeenCalledWith('/knowledge-graphs/kg-abc-123/data-sources/new') }) it('uses id from API response — not a hardcoded value', async () => { // Different KG IDs to verify the implementation reads from the response, // not a hardcoded string. const testCases = [ - { apiId: 'kg-aaa-111', expectedUrl: '/data-sources?kg_id=kg-aaa-111' }, - { apiId: 'kg-bbb-222', expectedUrl: '/data-sources?kg_id=kg-bbb-222' }, - { apiId: 'kg-ccc-333', expectedUrl: '/data-sources?kg_id=kg-ccc-333' }, + { apiId: 'kg-aaa-111', expectedUrl: '/knowledge-graphs/kg-aaa-111/data-sources/new' }, + { apiId: 'kg-bbb-222', expectedUrl: '/knowledge-graphs/kg-bbb-222/data-sources/new' }, + { apiId: 'kg-ccc-333', expectedUrl: '/knowledge-graphs/kg-ccc-333/data-sources/new' }, ] for (const { apiId, expectedUrl } of testCases) { @@ -1133,7 +1132,7 @@ describe('Knowledge Graph Creation — KG-ID-scoped navigation (Task-101)', () = method: 'POST', body: { name: createName.value.trim() }, }) - navigateTo(`/data-sources?kg_id=${result.id}`) + navigateTo(`/knowledge-graphs/${result.id}/data-sources/new`) } finally { creating.value = false } diff --git a/src/dev-ui/app/tests/task-121-spec-alignment.test.ts b/src/dev-ui/app/tests/task-121-spec-alignment.test.ts index 63eda2df6..10f545c9a 100644 --- a/src/dev-ui/app/tests/task-121-spec-alignment.test.ts +++ b/src/dev-ui/app/tests/task-121-spec-alignment.test.ts @@ -49,10 +49,8 @@ const KG_INDEX_VUE = readFileSync( describe('Task-121 — Requirement: Knowledge Graph Creation', () => { describe('Post-creation prompt: navigates to data-sources wizard with new KG scoped', () => { - it('knowledge-graphs page emits navigateTo to /data-sources?kg_id=', () => { - // The toast action must direct the user to /data-sources scoped to the new - // knowledge graph so the wizard pre-opens with the correct KG selected. - expect(KG_INDEX_VUE).toContain('/data-sources?kg_id=') + it('knowledge-graphs page navigates to kg-scoped onboarding after create', () => { + expect(KG_INDEX_VUE).toContain('/knowledge-graphs/${result.id}/data-sources/new') }) it('knowledge-graphs page constructs the navigation URL from the API result id', () => { @@ -404,36 +402,20 @@ describe('Task-121 — Requirement: Backend API Alignment — Parent context', ( method: 'POST', body: { name: createName.value }, }) - // 2. Post-creation: navigate to data-sources with new KG ID - postCreationUrl = `/data-sources?kg_id=${result.id}` + postCreationUrl = `/knowledge-graphs/${result.id}/data-sources/new` navigateTo(postCreationUrl) } await handleCreate() - // 3. The URL includes the exact KG ID returned by the API - expect(postCreationUrl).toBe('/data-sources?kg_id=kg-new-789') - expect(navigateTo).toHaveBeenCalledWith('/data-sources?kg_id=kg-new-789') + expect(postCreationUrl).toBe('/knowledge-graphs/kg-new-789/data-sources/new') + expect(navigateTo).toHaveBeenCalledWith('/knowledge-graphs/kg-new-789/data-sources/new') - // 4. The data-sources page would extract this param and call openWizard - const routeQuery = { kg_id: 'kg-new-789' } - const preselectedKgId = routeQuery.kg_id as string | undefined - expect(preselectedKgId).toBe('kg-new-789') - - // 5. openWizard initialises selectedKnowledgeGraphId with the param - const wizardState = { selectedKnowledgeGraphId: '' } - function openWizard(preselectedId?: string) { - wizardState.selectedKnowledgeGraphId = preselectedId ?? '' - } - openWizard(preselectedKgId) - expect(wizardState.selectedKnowledgeGraphId).toBe('kg-new-789') - - // 6. Step-1 can advance immediately (adapter still needs selection, but KG is set) - expect(canAdvanceStep1('github', wizardState.selectedKnowledgeGraphId)).toBe(true) - - // 7. Creation URL uses the pre-selected KG ID - const creationUrl = buildDataSourceCreationUrl(wizardState.selectedKnowledgeGraphId) - expect(creationUrl).toBe('/management/knowledge-graphs/kg-new-789/data-sources') + const kgIdFromRoute = 'kg-new-789' + expect(canAdvanceStep1('github', kgIdFromRoute)).toBe(true) + expect(buildDataSourceCreationUrl(kgIdFromRoute)).toBe( + '/management/knowledge-graphs/kg-new-789/data-sources', + ) }) }) }) diff --git a/src/dev-ui/app/utils/kgDataSourcesNavigation.ts b/src/dev-ui/app/utils/kgDataSourcesNavigation.ts new file mode 100644 index 000000000..c17f7f154 --- /dev/null +++ b/src/dev-ui/app/utils/kgDataSourcesNavigation.ts @@ -0,0 +1,39 @@ +/** + * Knowledge-graph–scoped data source routes (manage workspace entry points). + * + * Mirrors k-extract's split between `/designer/new` (first-time onboarding) and + * `/projects/:name/phase1` (ongoing data source operations). + */ + +export type KgDataSourcesFocus = 'maintain' + +export function buildKgDataSourcesNewUrl(kgId: string): string { + return `/knowledge-graphs/${encodeURIComponent(kgId)}/data-sources/new` +} + +export function buildKgDataSourcesUrl(kgId: string, opts?: { focus?: KgDataSourcesFocus }): string { + const base = `/knowledge-graphs/${encodeURIComponent(kgId)}/data-sources` + if (opts?.focus === 'maintain') { + return `${base}?focus=maintain` + } + return base +} + +export function buildKgManageUrl(kgId: string): string { + return `/knowledge-graphs/${encodeURIComponent(kgId)}/manage` +} + +/** + * Where "Data Sources" from KG manage should land. + * Zero sources → onboarding wizard; otherwise → operations page (phase1 equivalent). + */ +export function resolveKgDataSourcesEntryUrl(kgId: string, dataSourceCount: number): string { + if (dataSourceCount <= 0) { + return buildKgDataSourcesNewUrl(kgId) + } + return buildKgDataSourcesUrl(kgId) +} + +export function parseKgDataSourcesFocusQuery(focus: unknown): KgDataSourcesFocus | null { + return focus === 'maintain' ? 'maintain' : null +} diff --git a/src/dev-ui/app/utils/kgDataSourcesSync.ts b/src/dev-ui/app/utils/kgDataSourcesSync.ts new file mode 100644 index 000000000..ebaf754eb --- /dev/null +++ b/src/dev-ui/app/utils/kgDataSourcesSync.ts @@ -0,0 +1,41 @@ +export type SyncRunStatus = + | 'pending' + | 'ingesting' + | 'ai_extracting' + | 'applying' + | 'completed' + | 'failed' + +export const ACTIVE_SYNC_STATUSES: SyncRunStatus[] = [ + 'pending', + 'ingesting', + 'ai_extracting', + 'applying', +] + +export function isActiveSyncStatus(status: SyncRunStatus | undefined): boolean { + if (!status) return false + return ACTIVE_SYNC_STATUSES.includes(status) +} + +export function isSyncTerminal(status: SyncRunStatus | undefined): boolean { + return status === 'completed' || status === 'failed' +} + +export interface SyncRunSummary { + id: string + status: SyncRunStatus + error: string | null + token_usage_total?: number | null + cost_total_usd?: number | null +} + +export function latestSyncRun<T extends SyncRunSummary>(runs: T[] | undefined): T | undefined { + return runs?.[0] +} + +export function hasAnyActiveSync<T extends { sync_runs?: SyncRunSummary[] }>( + sources: T[], +): boolean { + return sources.some((ds) => isActiveSyncStatus(latestSyncRun(ds.sync_runs)?.status)) +} diff --git a/src/dev-ui/app/utils/kgManageWorkspace.ts b/src/dev-ui/app/utils/kgManageWorkspace.ts index 7bec05d4c..6e182d51a 100644 --- a/src/dev-ui/app/utils/kgManageWorkspace.ts +++ b/src/dev-ui/app/utils/kgManageWorkspace.ts @@ -1,5 +1,14 @@ +import { + buildKgDataSourcesUrl, + resolveKgDataSourcesEntryUrl, +} from '@/utils/kgDataSourcesNavigation' + export type WorkspaceStepId = 'data-sources' | 'graph-management' | 'mutation-logs' | 'maintain' +export interface StepDestinationContext { + dataSourceCount: number +} + export type StepStatusLabel = 'ready' | 'in_progress' | 'needs_attention' | 'blocked' export type StepActionLabel = 'Open' | 'Revisit' | 'Run' @@ -62,12 +71,12 @@ export function isMaintenanceReady(ds: { return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit } -export function buildDataSourcesStepUrl(kgId: string): string { - return `/data-sources?kg_id=${encodeURIComponent(kgId)}&from=manage` +export function buildDataSourcesStepUrl(kgId: string, dataSourceCount = 0): string { + return resolveKgDataSourcesEntryUrl(kgId, dataSourceCount) } export function buildMaintainStepUrl(kgId: string): string { - return `/data-sources?kg_id=${encodeURIComponent(kgId)}&from=manage&focus=maintain` + return buildKgDataSourcesUrl(kgId, { focus: 'maintain' }) } export function buildManageStepUrl(kgId: string, step?: WorkspaceStepId): string { @@ -306,10 +315,15 @@ export function buildSuggestedNextStep(input: WorkspaceOverviewInputs): Suggeste } } -export function resolveStepDestination(kgId: string, stepId: WorkspaceStepId): string { +export function resolveStepDestination( + kgId: string, + stepId: WorkspaceStepId, + context?: StepDestinationContext, +): string { + const dataSourceCount = context?.dataSourceCount ?? 0 switch (stepId) { case 'data-sources': - return buildDataSourcesStepUrl(kgId) + return buildDataSourcesStepUrl(kgId, dataSourceCount) case 'maintain': return buildMaintainStepUrl(kgId) case 'graph-management': From cbc709e105a1e25015545b50e0ea68eb03cd8869 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Wed, 27 May 2026 16:17:37 -0400 Subject: [PATCH 55/80] feat(sync): add ingest-only pipeline and ingested status for KG onboarding prep --- env/api.env | 2 +- ...2d3e4f5a6b_add_ingested_sync_run_status.py | 38 ++++++++ .../ingestion/infrastructure/event_handler.py | 86 +++++++++++------ src/api/main.py | 1 + .../services/data_source_service.py | 10 +- .../domain/aggregates/data_source.py | 3 + .../domain/entities/data_source_sync_run.py | 17 +++- .../management/domain/events/data_source.py | 3 + .../value_objects/sync_pipeline_mode.py | 7 ++ .../models/data_source_sync_run.py | 3 +- .../infrastructure/sync_lifecycle_handler.py | 23 ++++- .../presentation/data_sources/models.py | 17 +++- .../presentation/data_sources/routes.py | 5 + .../test_ingestion_event_handler.py | 32 +++++++ .../test_sync_lifecycle_handler.py | 30 +++++- .../presentation/test_data_sources_routes.py | 22 +++++ src/api/uv.lock | 2 +- .../components/graph/SyncPhaseIndicator.vue | 12 ++- .../[kgId]/data-sources/new.vue | 94 ++++++++----------- src/dev-ui/app/utils/kgDataSourcesSync.ts | 3 +- 20 files changed, 314 insertions(+), 96 deletions(-) create mode 100644 src/api/infrastructure/migrations/versions/fc2d3e4f5a6b_add_ingested_sync_run_status.py create mode 100644 src/api/management/domain/value_objects/sync_pipeline_mode.py diff --git a/env/api.env b/env/api.env index 781143af9..868ea6bf0 100644 --- a/env/api.env +++ b/env/api.env @@ -10,7 +10,7 @@ SPICEDB_ENDPOINT="spicedb:50051" SPICEDB_PRESHARED_KEY="changeme" KARTOGRAPH_CORS_ORIGINS=["http://localhost:3000"] KARTOGRAPH_IAM_BOOTSTRAP_ADMIN_USERNAMES='["alice"]' -KARTOGRAPH_IAM_SINGLE_TENANT_MODE=false +KARTOGRAPH_IAM_SINGLE_TENANT_MODE=true # Generate with uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" KARTOGRAPH_MGMT_ENCRYPTION_KEY="vwN4rUcH-KL-UyJsL8hc6apftRUTovwec6L2M5uF5OE=" # Extraction runtime defaults to in-memory adapters. Set backend=container and diff --git a/src/api/infrastructure/migrations/versions/fc2d3e4f5a6b_add_ingested_sync_run_status.py b/src/api/infrastructure/migrations/versions/fc2d3e4f5a6b_add_ingested_sync_run_status.py new file mode 100644 index 000000000..b7ab2358d --- /dev/null +++ b/src/api/infrastructure/migrations/versions/fc2d3e4f5a6b_add_ingested_sync_run_status.py @@ -0,0 +1,38 @@ +"""add ingested sync run status + +Adds ``ingested`` as a terminal sync-run status for ingest-only pipeline runs +that prepare ingestion context without AI extraction. + +Revision ID: fc2d3e4f5a6b +Revises: fb1c2d3e4f5a +Create Date: 2026-05-26 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "fc2d3e4f5a6b" +down_revision: Union[str, Sequence[str], None] = "fb1c2d3e4f5a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint("ck_sync_runs_status", "data_source_sync_runs") + op.create_check_constraint( + "ck_sync_runs_status", + "data_source_sync_runs", + "status IN ('pending', 'ingesting', 'ai_extracting', 'applying', " + "'ingested', 'completed', 'failed')", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_sync_runs_status", "data_source_sync_runs") + op.create_check_constraint( + "ck_sync_runs_status", + "data_source_sync_runs", + "status IN ('pending', 'ingesting', 'ai_extracting', 'applying', " + "'completed', 'failed')", + ) diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index 6eb08ffb2..788920217 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -94,20 +94,38 @@ async def handle( knowledge_graph_id = payload["knowledge_graph_id"] now = datetime.now(UTC) + pipeline_mode = payload.get("pipeline_mode", "full") + ingest_only = pipeline_mode == "ingest_only" + if payload.get("no_changes_detected") is True: - await self._outbox.append( - event_type="MutationsApplied", - payload={ - "sync_run_id": sync_run_id, - "data_source_id": data_source_id, - "knowledge_graph_id": knowledge_graph_id, - "no_changes_detected": True, - "occurred_at": now.isoformat(), - }, - occurred_at=now, - aggregate_type="sync_run", - aggregate_id=sync_run_id, - ) + if ingest_only: + await self._outbox.append( + event_type="IngestionPrepared", + payload={ + "sync_run_id": sync_run_id, + "data_source_id": data_source_id, + "knowledge_graph_id": knowledge_graph_id, + "no_changes_detected": True, + "occurred_at": now.isoformat(), + }, + occurred_at=now, + aggregate_type="sync_run", + aggregate_id=sync_run_id, + ) + else: + await self._outbox.append( + event_type="MutationsApplied", + payload={ + "sync_run_id": sync_run_id, + "data_source_id": data_source_id, + "knowledge_graph_id": knowledge_graph_id, + "no_changes_detected": True, + "occurred_at": now.isoformat(), + }, + occurred_at=now, + aggregate_type="sync_run", + aggregate_id=sync_run_id, + ) return try: @@ -119,7 +137,6 @@ async def handle( connection_config=payload.get("connection_config", {}), credentials_path=payload.get("credentials_path"), tenant_id=payload.get("tenant_id"), - credentials=payload.get("credentials"), credentials=runtime_credentials or payload.get("credentials"), baseline_commit=payload.get("baseline_commit"), ) @@ -144,16 +161,31 @@ async def handle( # Ingestion succeeded — append success event outside the try block so # that an outbox write failure is not misclassified as IngestionFailed. - await self._outbox.append( - event_type="JobPackageProduced", - payload={ - "sync_run_id": sync_run_id, - "data_source_id": data_source_id, - "knowledge_graph_id": knowledge_graph_id, - "job_package_id": str(job_package_id), - "occurred_at": now.isoformat(), - }, - occurred_at=now, - aggregate_type="sync_run", - aggregate_id=sync_run_id, - ) + if ingest_only: + await self._outbox.append( + event_type="IngestionPrepared", + payload={ + "sync_run_id": sync_run_id, + "data_source_id": data_source_id, + "knowledge_graph_id": knowledge_graph_id, + "job_package_id": str(job_package_id), + "occurred_at": now.isoformat(), + }, + occurred_at=now, + aggregate_type="sync_run", + aggregate_id=sync_run_id, + ) + else: + await self._outbox.append( + event_type="JobPackageProduced", + payload={ + "sync_run_id": sync_run_id, + "data_source_id": data_source_id, + "knowledge_graph_id": knowledge_graph_id, + "job_package_id": str(job_package_id), + "occurred_at": now.isoformat(), + }, + occurred_at=now, + aggregate_type="sync_run", + aggregate_id=sync_run_id, + ) diff --git a/src/api/main.py b/src/api/main.py index 257c9f0a6..63fd2a7a6 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -87,6 +87,7 @@ class _SessionedSyncLifecycleHandler: { "SyncStarted", "JobPackageProduced", + "IngestionPrepared", "IngestionFailed", "MutationLogProduced", "ExtractionFailed", diff --git a/src/api/management/application/services/data_source_service.py b/src/api/management/application/services/data_source_service.py index b128052fc..fea3551cc 100644 --- a/src/api/management/application/services/data_source_service.py +++ b/src/api/management/application/services/data_source_service.py @@ -604,12 +604,16 @@ async def trigger_sync( self, user_id: str, ds_id: str, + *, + pipeline_mode: str = "full", ) -> DataSourceSyncRun: """Trigger a sync for a data source. Args: user_id: The user triggering the sync ds_id: The data source ID + pipeline_mode: ``full`` (default) or ``ingest_only`` to prepare ingestion + context without running AI extraction or graph application Returns: The created DataSourceSyncRun entity @@ -658,7 +662,11 @@ async def trigger_sync( # Record SyncStarted event on the data source aggregate. # This event carries the sync_run_id so lifecycle handlers # can update the correct sync run record. - ds.request_sync(sync_run_id=sync_run.id, requested_by=user_id) + ds.request_sync( + sync_run_id=sync_run.id, + requested_by=user_id, + pipeline_mode=pipeline_mode, + ) await self._ds_repo.save(ds) await self._session.commit() diff --git a/src/api/management/domain/aggregates/data_source.py b/src/api/management/domain/aggregates/data_source.py index af30f166e..b0dac1b28 100644 --- a/src/api/management/domain/aggregates/data_source.py +++ b/src/api/management/domain/aggregates/data_source.py @@ -311,6 +311,7 @@ def request_sync( sync_run_id: str, *, requested_by: str | None = None, + pipeline_mode: str = "full", ) -> None: """Request a sync for this data source. @@ -321,6 +322,7 @@ def request_sync( Args: sync_run_id: The ID of the sync run record created for this sync requested_by: The user who requested the sync (optional) + pipeline_mode: ``full`` or ``ingest_only`` — see SyncStarted.pipeline_mode Raises: AggregateDeletedError: If the data source has been marked for deletion @@ -338,6 +340,7 @@ def request_sync( credentials_path=self.credentials_path, occurred_at=datetime.now(UTC), requested_by=requested_by, + pipeline_mode=pipeline_mode, ) ) diff --git a/src/api/management/domain/entities/data_source_sync_run.py b/src/api/management/domain/entities/data_source_sync_run.py index 9bf466518..3c802a6dc 100644 --- a/src/api/management/domain/entities/data_source_sync_run.py +++ b/src/api/management/domain/entities/data_source_sync_run.py @@ -7,9 +7,17 @@ from typing import Any # Valid sync run status values representing the lifecycle state machine. -TERMINAL_STATUSES = frozenset({"completed", "failed"}) +TERMINAL_STATUSES = frozenset({"ingested", "completed", "failed"}) VALID_STATUSES = frozenset( - {"pending", "ingesting", "ai_extracting", "applying", "completed", "failed"} + { + "pending", + "ingesting", + "ai_extracting", + "applying", + "ingested", + "completed", + "failed", + } ) @@ -83,13 +91,14 @@ class DataSourceSyncRun: → ingesting (SyncStarted event processed, ingestion pipeline running) → ai_extracting (JobPackageProduced, AI entity extraction triggered) → applying (MutationLogProduced, graph mutations being applied) + → ingested (IngestionPrepared, context ready — no extraction) → completed (MutationsApplied, sync finished successfully) → failed (IngestionFailed / ExtractionFailed / MutationApplicationFailed) - Terminal states: completed, failed — no further transitions allowed. + Terminal states: ingested, completed, failed — no further transitions allowed. Valid status values: "pending", "ingesting", "ai_extracting", - "applying", "completed", "failed" + "applying", "ingested", "completed", "failed" """ id: str diff --git a/src/api/management/domain/events/data_source.py b/src/api/management/domain/events/data_source.py index 3ebdeafba..6a3ae0922 100644 --- a/src/api/management/domain/events/data_source.py +++ b/src/api/management/domain/events/data_source.py @@ -87,6 +87,8 @@ class SyncStarted: credentials_path: Optional path to credentials in vault occurred_at: When the sync was initiated requested_by: The user who requested the sync (if known) + pipeline_mode: ``full`` runs ingestion through graph apply; ``ingest_only`` + stops after ingestion context is prepared (no AI extraction). """ sync_run_id: str @@ -98,3 +100,4 @@ class SyncStarted: occurred_at: datetime credentials_path: str | None = None requested_by: str | None = None + pipeline_mode: str = "full" diff --git a/src/api/management/domain/value_objects/sync_pipeline_mode.py b/src/api/management/domain/value_objects/sync_pipeline_mode.py new file mode 100644 index 000000000..b8e1f9999 --- /dev/null +++ b/src/api/management/domain/value_objects/sync_pipeline_mode.py @@ -0,0 +1,7 @@ +"""Sync pipeline mode — controls how far a sync run progresses.""" + +from typing import Literal + +SyncPipelineMode = Literal["full", "ingest_only"] + +DEFAULT_SYNC_PIPELINE_MODE: SyncPipelineMode = "full" diff --git a/src/api/management/infrastructure/models/data_source_sync_run.py b/src/api/management/infrastructure/models/data_source_sync_run.py index d1401fe96..2af41a7bd 100644 --- a/src/api/management/infrastructure/models/data_source_sync_run.py +++ b/src/api/management/infrastructure/models/data_source_sync_run.py @@ -39,6 +39,7 @@ class DataSourceSyncRunModel(Base): - ingesting: Data extraction pipeline is running - ai_extracting: AI entity extraction is in progress - applying: Graph mutations are being applied + - ingested: Ingestion context prepared without extraction (terminal) - completed: Sync finished successfully (terminal) - failed: Sync failed at any stage (terminal) """ @@ -79,7 +80,7 @@ class DataSourceSyncRunModel(Base): Index("idx_sync_runs_data_source_status", "data_source_id", "status"), CheckConstraint( "status IN ('pending', 'ingesting', 'ai_extracting', 'applying', " - "'completed', 'failed')", + "'ingested', 'completed', 'failed')", name="ck_sync_runs_status", ), ) diff --git a/src/api/management/infrastructure/sync_lifecycle_handler.py b/src/api/management/infrastructure/sync_lifecycle_handler.py index c33ee1d65..bcfc22141 100644 --- a/src/api/management/infrastructure/sync_lifecycle_handler.py +++ b/src/api/management/infrastructure/sync_lifecycle_handler.py @@ -10,10 +10,11 @@ IngestionFailed → failed (with error) MutationLogProduced → applying ExtractionFailed → failed (with error) + IngestionPrepared → ingested (ingestion context ready; no extraction) MutationsApplied → completed (DataSource.last_sync_at updated) MutationApplicationFailed → failed (with error) -Terminal states (completed, failed) cannot be transitioned further. +Terminal states (ingested, completed, failed) cannot be transitioned further. """ from __future__ import annotations @@ -43,6 +44,7 @@ "JobPackageProduced": "ai_extracting", "MutationLogProduced": "applying", "MutationsApplied": "completed", + "IngestionPrepared": "ingested", } _SUPPORTED_EVENTS = frozenset(_STATUS_MAP.keys()) | _FAILURE_EVENTS @@ -119,6 +121,25 @@ async def handle( sync_run.completed_at = now sync_run.logs.append(f"[{now.isoformat()}] {event_type}: {error_msg}") + elif event_type == "IngestionPrepared": + sync_run.status = "ingested" + sync_run.completed_at = now + job_package_id = payload.get("job_package_id") + if job_package_id: + sync_run.logs.append( + f"[{now.isoformat()}] Ingestion context prepared " + f"(job_package_id={job_package_id})" + ) + elif payload.get("no_changes_detected") is True: + sync_run.logs.append( + f"[{now.isoformat()}] No source changes detected; " + "ingestion context preparation skipped." + ) + else: + sync_run.logs.append( + f"[{now.isoformat()}] Ingestion context prepared for later extraction." + ) + elif event_type == "MutationsApplied": sync_run.status = "completed" sync_run.completed_at = now diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index 903d494aa..57e826746 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -309,6 +309,18 @@ class DataSourceDiffSummaryResponse(BaseModel): ) +class TriggerSyncRequest(BaseModel): + """Request body for triggering a data source sync.""" + + mode: Literal["full", "ingest_only"] = Field( + default="full", + description=( + "Pipeline mode: full runs ingestion through graph apply; " + "ingest_only prepares ingestion context without extraction" + ), + ) + + class SyncRunResponse(BaseModel): """Response model for a data source sync run.""" @@ -316,7 +328,10 @@ class SyncRunResponse(BaseModel): data_source_id: str = Field(..., description="Data Source ID this run belongs to") status: str = Field( ..., - description="Sync run status (pending, ingesting, ai_extracting, applying, completed, failed)", + description=( + "Sync run status (pending, ingesting, ai_extracting, applying, " + "ingested, completed, failed)" + ), ) started_at: datetime = Field(..., description="When the sync run started") completed_at: datetime | None = Field( diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index c73f1b16f..4610ec3ca 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -32,6 +32,7 @@ MutationLogEntryPreviewPageResponse, SyncRunLogsResponse, SyncRunResponse, + TriggerSyncRequest, UpdateDataSourceRequest, ) from shared_kernel.datasource_types import DataSourceAdapterType @@ -370,6 +371,7 @@ async def trigger_sync( ds_id: str, current_user: Annotated[CurrentUser, Depends(get_current_user)], service: Annotated[DataSourceService, Depends(get_data_source_service)], + body: TriggerSyncRequest | None = None, ) -> SyncRunResponse: """Trigger a synchronization for a data source. @@ -380,6 +382,7 @@ async def trigger_sync( ds_id: Data Source ID to trigger sync for current_user: Current authenticated user with tenant context service: Data source service for orchestration + body: Optional pipeline mode (default full sync) Returns: SyncRunResponse with the created sync run details @@ -389,10 +392,12 @@ async def trigger_sync( HTTPException: 404 if DS not found HTTPException: 500 for unexpected errors """ + request = body or TriggerSyncRequest() try: sync_run = await service.trigger_sync( user_id=current_user.user_id.value, ds_id=ds_id, + pipeline_mode=request.mode, ) return SyncRunResponse.from_domain(sync_run) diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index 0b1e6069b..ec77a0b35 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -238,6 +238,37 @@ async def test_short_circuits_when_no_changes_detected( assert event["payload"]["sync_run_id"] == "run-004" assert event["payload"]["no_changes_detected"] is True + async def test_emits_ingestion_prepared_when_ingest_only( + self, + handler: IngestionEventHandler, + outbox: _FakeOutboxRepository, + ): + """ingest_only mode should stop after ingestion without JobPackageProduced.""" + payload = _sync_started_payload(sync_run_id="run-ingest") + payload["pipeline_mode"] = "ingest_only" + await handler.handle("SyncStarted", payload) + + assert len(outbox.appended) == 1 + event = outbox.appended[0] + assert event["event_type"] == "IngestionPrepared" + assert event["payload"]["job_package_id"] is not None + + async def test_no_changes_ingest_only_emits_ingestion_prepared( + self, + handler: IngestionEventHandler, + ingestion_service: _FakeIngestionService, + outbox: _FakeOutboxRepository, + ): + """ingest_only with no_changes_detected should not emit MutationsApplied.""" + payload = _sync_started_payload(sync_run_id="run-nc-ingest") + payload["pipeline_mode"] = "ingest_only" + payload["no_changes_detected"] = True + + await handler.handle("SyncStarted", payload) + + assert ingestion_service.calls == [] + assert outbox.appended[0]["event_type"] == "IngestionPrepared" + @pytest.mark.asyncio class TestIngestionEventHandlerFailure: @@ -278,6 +309,7 @@ async def run( # type: ignore[override] adapter_type: str, connection_config: dict[str, str], credentials_path: str | None, + tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, ) -> JobPackageId: diff --git a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py index 035afd82e..bd9b9a9a5 100644 --- a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py +++ b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py @@ -83,10 +83,11 @@ class TestSyncLifecycleHandlerSupportedEvents: """Tests for supported_event_types().""" def test_supports_all_lifecycle_events(self, handler: SyncLifecycleHandler): - """Handler should support all 7 lifecycle events.""" + """Handler should support all lifecycle events.""" expected = { "SyncStarted", "JobPackageProduced", + "IngestionPrepared", "IngestionFailed", "MutationLogProduced", "ExtractionFailed", @@ -116,6 +117,33 @@ async def test_sync_started_sets_ingesting( assert saved_run.status == "ingesting" +@pytest.mark.asyncio +class TestIngestionPreparedTransition: + """IngestionPrepared → status = ingested (terminal, no last_sync_at).""" + + async def test_ingestion_prepared_sets_ingested( + self, + handler: SyncLifecycleHandler, + mock_sync_run_repo: AsyncMock, + mock_ds_repo: AsyncMock, + ): + run = _make_sync_run(status="ingesting") + mock_sync_run_repo.get_by_id.return_value = run + + await handler.handle( + "IngestionPrepared", + _payload( + sync_run_id=run.id, + job_package_id="pkg-001", + ), + ) + + saved_run: DataSourceSyncRun = mock_sync_run_repo.save.call_args[0][0] + assert saved_run.status == "ingested" + assert saved_run.completed_at is not None + mock_ds_repo.get_by_id.assert_not_called() + + @pytest.mark.asyncio class TestJobPackageProducedTransition: """JobPackageProduced → status = ai_extracting.""" diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 5ad48dae2..ec92b4784 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -386,6 +386,28 @@ def test_trigger_sync_calls_service_with_correct_params( mock_ds_service.trigger_sync.assert_called_once_with( user_id=mock_current_user.user_id.value, ds_id=sample_data_source.id.value, + pipeline_mode="full", + ) + + def test_trigger_sync_passes_ingest_only_mode( + self, + test_client: TestClient, + mock_ds_service: AsyncMock, + sample_data_source: DataSource, + sample_sync_run: DataSourceSyncRun, + mock_current_user: CurrentUser, + ) -> None: + mock_ds_service.trigger_sync.return_value = sample_sync_run + + test_client.post( + f"/management/data-sources/{sample_data_source.id.value}/sync", + json={"mode": "ingest_only"}, + ) + + mock_ds_service.trigger_sync.assert_called_once_with( + user_id=mock_current_user.user_id.value, + ds_id=sample_data_source.id.value, + pipeline_mode="ingest_only", ) def test_trigger_sync_returns_403_when_unauthorized( diff --git a/src/api/uv.lock b/src/api/uv.lock index 6dc4cb007..9e964cae4 100644 --- a/src/api/uv.lock +++ b/src/api/uv.lock @@ -1289,7 +1289,7 @@ wheels = [ [[package]] name = "kartograph-api" -version = "3.36.1" +version = "3.37.1" source = { virtual = "." } dependencies = [ { name = "alembic" }, diff --git a/src/dev-ui/app/components/graph/SyncPhaseIndicator.vue b/src/dev-ui/app/components/graph/SyncPhaseIndicator.vue index 2b2cd02c2..0246930f0 100644 --- a/src/dev-ui/app/components/graph/SyncPhaseIndicator.vue +++ b/src/dev-ui/app/components/graph/SyncPhaseIndicator.vue @@ -3,7 +3,14 @@ import { computed } from 'vue' import { Loader2, Download, Sparkles, Database, Clock } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' -type SyncStatus = 'pending' | 'ingesting' | 'ai_extracting' | 'applying' | 'completed' | 'failed' +type SyncStatus = + | 'pending' + | 'ingesting' + | 'ai_extracting' + | 'applying' + | 'ingested' + | 'completed' + | 'failed' const props = defineProps<{ status: SyncStatus; label?: string }>() @@ -13,6 +20,7 @@ const phaseLabel = computed(() => { ingesting: 'Ingesting', ai_extracting: 'Extracting', applying: 'Applying', + ingested: 'Prepared', completed: 'Completed', failed: 'Failed', } @@ -24,7 +32,7 @@ const isActive = computed(() => ) const badgeVariant = computed<'default' | 'secondary' | 'destructive'>(() => { - if (props.status === 'completed') return 'default' + if (props.status === 'completed' || props.status === 'ingested') return 'default' if (props.status === 'failed') return 'destructive' return 'secondary' }) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue index 98ec4254d..8a5344106 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue @@ -111,16 +111,8 @@ const syncProgressPercent = computed(() => { return Math.round((syncCompletedInRun.value / syncRunTotal.value) * 100) }) -const completedSyncCount = computed(() => - createdSources.value.filter((s) => s.syncStatus === 'completed').length, -) - -const totalTokenUsage = computed(() => - createdSources.value.reduce((sum, s) => sum + (s.token_usage_total ?? 0), 0), -) - -const totalSyncCost = computed(() => - createdSources.value.reduce((sum, s) => sum + (s.cost_total_usd ?? 0), 0), +const preparedSourceCount = computed(() => + createdSources.value.filter((s) => s.syncStatus === 'ingested').length, ) function addUrlField() { @@ -333,12 +325,12 @@ async function pollUntilTerminal(row: CreatedSourceRow, timeoutMs = 600_000) { row.syncError = 'Sync timed out' } -async function runSequentialSync() { +async function runSequentialIngestionPrep() { const queue = createdSources.value.filter( (s) => s.syncStatus === 'idle' || s.syncStatus === 'failed' || s.syncStatus === 'queued', ) if (queue.length === 0) { - toast.error('No sources need syncing') + toast.error('No sources need preparation') return } @@ -356,35 +348,38 @@ async function runSequentialSync() { target.syncError = null try { - await apiFetch(`/management/data-sources/${target.id}/sync`, { method: 'POST' }) + await apiFetch(`/management/data-sources/${target.id}/sync`, { + method: 'POST', + body: { mode: 'ingest_only' }, + }) await pollUntilTerminal(target) if (target.syncStatus === 'failed') { - toast.error(`Sync failed: ${target.name}`, { + toast.error(`Preparation failed: ${target.name}`, { description: target.syncError ?? undefined, }) } } catch (err: unknown) { target.syncStatus = 'failed' - target.syncError = err instanceof Error ? err.message : 'Sync failed' - toast.error(`Sync failed: ${target.name}`, { description: target.syncError }) + target.syncError = err instanceof Error ? err.message : 'Preparation failed' + toast.error(`Preparation failed: ${target.name}`, { description: target.syncError }) } syncCompletedInRun.value = i + 1 } - const allCompleted = createdSources.value.every((s) => s.syncStatus === 'completed') - readyForStats.value = allCompleted + const allPrepared = createdSources.value.every((s) => s.syncStatus === 'ingested') + readyForStats.value = allPrepared - if (allCompleted) { + if (allPrepared) { flowPhase.value = 'stats' await nextTick() wizardSectionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - toast.success('Initial sync complete', { - description: 'Review results below, then open data sources to continue.', + toast.success('Ingestion context prepared', { + description: 'Sources are ready for design and extraction when you open those steps.', }) } else { - toast('Sync finished with issues', { - description: 'Fix failed sources from the data sources page or retry sync.', + toast('Preparation finished with issues', { + description: 'Fix failed sources from the data sources page or retry.', }) } } finally { @@ -396,6 +391,8 @@ async function runSequentialSync() { function getSyncBadge(status: CreatedSourceRow['syncStatus']) { switch (status) { + case 'ingested': + return { variant: 'default' as const, label: 'Prepared', icon: Check } case 'completed': return { variant: 'default' as const, label: 'Completed', icon: Check } case 'failed': @@ -404,7 +401,7 @@ function getSyncBadge(status: CreatedSourceRow['syncStatus']) { case 'ingesting': case 'ai_extracting': case 'applying': - return { variant: 'secondary' as const, label: 'Syncing…', icon: Loader2 } + return { variant: 'secondary' as const, label: 'Preparing…', icon: Loader2 } default: return { variant: 'outline' as const, label: 'Ready', icon: null } } @@ -495,7 +492,7 @@ onUnmounted(() => { > <Loader2 v-if="detectingSourceDetails" class="mr-2 size-4 animate-spin" /> Continue - <ArrowRight v-else class="ml-2 size-4" /> + <ArrowRight v-if="!detectingSourceDetails" class="ml-2 size-4" /> </Button> </CardFooter> </Card> @@ -544,8 +541,8 @@ onUnmounted(() => { <Button variant="outline" type="button" @click="flowPhase = 'urls'">Back</Button> <Button type="button" :disabled="creating" @click="createDataSources"> <Loader2 v-if="creating" class="mr-2 size-4 animate-spin" /> - <Check v-else class="mr-2 size-4" /> - Connect data sources and sync + <Check v-if="!creating" class="mr-2 size-4" /> + Connect data sources </Button> </CardFooter> </Card> @@ -562,10 +559,11 @@ onUnmounted(() => { <Badge variant="default">{{ kgName }}</Badge> <span class="text-sm text-muted-foreground">sources connected</span> </div> - <CardTitle class="text-base">Initial sync</CardTitle> + <CardTitle class="text-base">Prepare ingestion context</CardTitle> <CardDescription> - Run ingestion and extraction for each source. Sources sync one at a time so you can - follow progress. + Fetch repository content and build job packages for each source. No AI extraction + runs here — that happens later in graph management. Sources are prepared one at a + time so you can follow progress. </CardDescription> </CardHeader> <CardContent class="space-y-4"> @@ -574,11 +572,11 @@ onUnmounted(() => { type="button" size="sm" :disabled="syncRunActive || createdSources.length === 0" - @click="runSequentialSync" + @click="runSequentialIngestionPrep" > <Loader2 v-if="syncRunActive" class="mr-2 size-4 animate-spin" /> - <GitBranch v-else class="mr-2 size-4" /> - Start initial sync + <GitBranch v-if="!syncRunActive" class="mr-2 size-4" /> + Prepare ingestion context </Button> </div> @@ -587,7 +585,7 @@ onUnmounted(() => { class="space-y-2 rounded-lg border border-primary/30 bg-primary/5 p-4" > <div class="flex items-center justify-between text-sm"> - <span class="font-medium">Syncing {{ syncActiveName || '…' }}</span> + <span class="font-medium">Preparing {{ syncActiveName || '…' }}</span> <span class="tabular-nums text-muted-foreground">{{ syncStepLabel }}</span> </div> <div class="h-2 overflow-hidden rounded-full bg-muted"> @@ -616,7 +614,7 @@ onUnmounted(() => { </div> <div class="flex shrink-0 flex-col items-end gap-2"> <SyncPhaseIndicator - v-if="isActiveSyncStatus(source.syncStatus as SyncRunStatus) || source.syncStatus === 'completed' || source.syncStatus === 'failed'" + v-if="isActiveSyncStatus(source.syncStatus as SyncRunStatus) || source.syncStatus === 'ingested' || source.syncStatus === 'completed' || source.syncStatus === 'failed'" :status="(source.syncStatus === 'idle' || source.syncStatus === 'queued') ? 'pending' : (source.syncStatus as SyncRunStatus)" /> <Badge v-else :variant="getSyncBadge(source.syncStatus).variant"> @@ -633,11 +631,11 @@ onUnmounted(() => { <CardHeader> <div class="flex items-center gap-2"> <Settings2 class="size-5 text-primary" /> - <CardTitle class="text-base">Sync summary</CardTitle> + <CardTitle class="text-base">Preparation summary</CardTitle> </div> <CardDescription> - Initial sync finished for all sources. Open data sources to manage commits, ontology, - and maintenance. + Ingestion context is ready for all sources. Open data sources to manage commits, or + continue in graph management when you are ready to extract. </CardDescription> </CardHeader> <CardContent class="space-y-4"> @@ -647,8 +645,6 @@ onUnmounted(() => { <tr class="border-b bg-muted/50 text-left"> <th class="px-3 py-2 font-medium">Data source</th> <th class="px-3 py-2 text-right font-medium">Status</th> - <th class="px-3 py-2 text-right font-medium">Tokens</th> - <th class="px-3 py-2 text-right font-medium">Cost (USD)</th> </tr> </thead> <tbody> @@ -659,27 +655,15 @@ onUnmounted(() => { > <td class="px-3 py-2 font-medium">{{ s.name }}</td> <td class="px-3 py-2 text-right"> - <Badge variant="default" class="text-[10px]">Completed</Badge> - </td> - <td class="px-3 py-2 text-right tabular-nums"> - {{ s.token_usage_total?.toLocaleString() ?? '—' }} - </td> - <td class="px-3 py-2 text-right tabular-nums text-muted-foreground"> - {{ s.cost_total_usd != null ? s.cost_total_usd.toFixed(4) : '—' }} + <Badge variant="default" class="text-[10px]">Prepared</Badge> </td> </tr> </tbody> </table> </div> <p class="text-sm text-muted-foreground"> - <span class="font-medium text-foreground">{{ completedSyncCount }}</span> - source{{ completedSyncCount === 1 ? '' : 's' }} synced. - <template v-if="totalTokenUsage > 0"> - Total tokens: {{ totalTokenUsage.toLocaleString() }}. - </template> - <template v-if="totalSyncCost > 0"> - Estimated cost: ${{ totalSyncCost.toFixed(4) }}. - </template> + <span class="font-medium text-foreground">{{ preparedSourceCount }}</span> + source{{ preparedSourceCount === 1 ? '' : 's' }} ready for later extraction. </p> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap"> <Button as-child> diff --git a/src/dev-ui/app/utils/kgDataSourcesSync.ts b/src/dev-ui/app/utils/kgDataSourcesSync.ts index ebaf754eb..b56757788 100644 --- a/src/dev-ui/app/utils/kgDataSourcesSync.ts +++ b/src/dev-ui/app/utils/kgDataSourcesSync.ts @@ -3,6 +3,7 @@ export type SyncRunStatus = | 'ingesting' | 'ai_extracting' | 'applying' + | 'ingested' | 'completed' | 'failed' @@ -19,7 +20,7 @@ export function isActiveSyncStatus(status: SyncRunStatus | undefined): boolean { } export function isSyncTerminal(status: SyncRunStatus | undefined): boolean { - return status === 'completed' || status === 'failed' + return status === 'ingested' || status === 'completed' || status === 'failed' } export interface SyncRunSummary { From 781fa38b25b8312b13bacb0e2781344ba0262350 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Thu, 28 May 2026 17:49:50 -0400 Subject: [PATCH 56/80] fix(ingestion): unblock SyncStarted handler shadowed import crash Remove the local get_management_settings import that caused UnboundLocalError and left sync runs stuck in ingesting after lifecycle updated. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/main.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/api/main.py b/src/api/main.py index 63fd2a7a6..b45a75cb9 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -196,9 +196,10 @@ async def _resolve_github_tracked_head_commit( return str(sha) if sha else None async def handle(self, event_type: str, payload: dict[str, Any]) -> None: - from infrastructure.outbox.repository import OutboxRepository + from ingestion.infrastructure.adapters.github import GitHubAdapter from ingestion.application.services.ingestion_service import IngestionService from ingestion.infrastructure.event_handler import IngestionEventHandler + from infrastructure.outbox.repository import OutboxRepository from management.domain.value_objects import DataSourceId from management.infrastructure.repositories.data_source_repository import ( DataSourceRepository, @@ -210,19 +211,6 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: async with self._session_factory() as session: outbox = OutboxRepository(session=session) ds_repo = DataSourceRepository(session=session, outbox=outbox) - management_settings = get_management_settings() - encryption_keys = management_settings.encryption_key.get_secret_value().split( - "," - ) - credential_reader = FernetSecretStore( - session=session, - encryption_keys=encryption_keys, - ) - from ingestion.infrastructure.adapters.github import GitHubAdapter - from infrastructure.settings import get_management_settings - from management.infrastructure.repositories.fernet_secret_store import ( - FernetSecretStore, - ) credential_reader = None if payload.get("credentials_path"): @@ -257,6 +245,7 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: data_source_id = str(payload.get("data_source_id", "")) tenant_id = str(payload.get("tenant_id", "")) if payload.get("tenant_id") else "" adapter_type = str(payload.get("adapter_type", "")) + credentials: dict[str, str] = {} if data_source_id and adapter_type == "github": ds = await ds_repo.get_by_id(DataSourceId(value=data_source_id)) if ds is not None: @@ -265,8 +254,7 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: ds.last_extraction_baseline_commit ) - credentials: dict[str, str] = {} - if ds.credentials_path and tenant_id: + if ds.credentials_path and tenant_id and credential_reader is not None: try: credentials = await credential_reader.retrieve( path=ds.credentials_path, From 1c212079d884114b9c6ad17d0347a438221fde4a Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Thu, 28 May 2026 18:22:09 -0400 Subject: [PATCH 57/80] feat(ui): align KG data sources page with k-extract phase1 layout Refresh commits updates tracked head only; advance extraction baseline on successful sync. Parallelize wizard ingest prep and add phase1-style overview table. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../services/data_source_service.py | 13 +- .../domain/aggregates/data_source.py | 16 + .../infrastructure/sync_lifecycle_handler.py | 1 + .../presentation/data_sources/models.py | 10 + .../presentation/data_sources/routes.py | 1 - .../application/test_data_source_service.py | 12 +- .../test_sync_lifecycle_handler.py | 3 + .../presentation/test_data_sources_routes.py | 12 +- src/dev-ui/app/pages/data-sources/index.vue | 7 +- .../[kgId]/data-sources/index.vue | 662 +++++++++++++----- .../[kgId]/data-sources/new.vue | 74 +- src/dev-ui/app/tests/data-sources.test.ts | 2 +- .../app/tests/kg-data-sources-phase1.test.ts | 72 ++ .../knowledge-graph-manage-workspace.test.ts | 2 + src/dev-ui/app/utils/kgDataSourcesCommits.ts | 78 +++ 15 files changed, 714 insertions(+), 251 deletions(-) create mode 100644 src/dev-ui/app/tests/kg-data-sources-phase1.test.ts create mode 100644 src/dev-ui/app/utils/kgDataSourcesCommits.ts diff --git a/src/api/management/application/services/data_source_service.py b/src/api/management/application/services/data_source_service.py index fea3551cc..87d8efecf 100644 --- a/src/api/management/application/services/data_source_service.py +++ b/src/api/management/application/services/data_source_service.py @@ -470,13 +470,12 @@ async def refresh_commit_references( user_id: str, ds_id: str, tracked_branch_head_commit: str, - clone_head_commit: str | None = None, ) -> DataSource: - """Persist refreshed source commit references for a data source. + """Persist the latest tracked branch head for a Git-backed data source. - Requires MANAGE permission on the data source. This action updates - tracked and clone commit references and initializes extraction baseline - on first refresh so per-source diff counts can be computed immediately. + Requires MANAGE permission. Updates only ``tracked_branch_head_commit``; + extraction baseline is advanced on successful sync completion or via + ``adopt_tracked_head_as_baseline``. """ has_manage = await self._check_permission( user_id=user_id, @@ -498,11 +497,7 @@ async def refresh_commit_references( if ds is None or ds.tenant_id != self._scope_to_tenant: raise ValueError(f"Data source {ds_id} not found") - resolved_clone_head = clone_head_commit or tracked_branch_head_commit ds.tracked_branch_head_commit = tracked_branch_head_commit - ds.clone_head_commit = resolved_clone_head - if ds.last_extraction_baseline_commit is None: - ds.last_extraction_baseline_commit = tracked_branch_head_commit await self._ds_repo.save(ds) await self._session.commit() diff --git a/src/api/management/domain/aggregates/data_source.py b/src/api/management/domain/aggregates/data_source.py index b0dac1b28..e61ee09a5 100644 --- a/src/api/management/domain/aggregates/data_source.py +++ b/src/api/management/domain/aggregates/data_source.py @@ -362,6 +362,22 @@ def record_sync_completed(self) -> None: tenant_id=self.tenant_id, ) + def advance_extraction_baseline_to_tracked_head(self) -> None: + """Move extraction baseline to the current tracked branch head. + + Called after graph mutations are applied so maintenance diffs reflect + the commit that was last extracted into the knowledge graph. + + Raises: + AggregateDeletedError: If the data source has been marked for deletion + """ + if self._deleted: + raise AggregateDeletedError( + "Cannot update extraction baseline on a deleted data source" + ) + if self.tracked_branch_head_commit: + self.last_extraction_baseline_commit = self.tracked_branch_head_commit + def mark_for_deletion( self, *, diff --git a/src/api/management/infrastructure/sync_lifecycle_handler.py b/src/api/management/infrastructure/sync_lifecycle_handler.py index bcfc22141..023f42112 100644 --- a/src/api/management/infrastructure/sync_lifecycle_handler.py +++ b/src/api/management/infrastructure/sync_lifecycle_handler.py @@ -227,4 +227,5 @@ async def _update_data_source_last_sync_at( return ds.record_sync_completed() + ds.advance_extraction_baseline_to_tracked_head() await self._ds_repo.save(ds) diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index 57e826746..1d3a380b8 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -199,6 +199,10 @@ class DataSourceResponse(BaseModel): tracked_branch_head_commit: str | None = Field( None, description="Latest known commit at the tracked source branch head" ) + connection_config: dict[str, str] = Field( + default_factory=dict, + description="Adapter connection configuration (non-secret)", + ) created_at: datetime = Field(..., description="When the DS was created") updated_at: datetime = Field(..., description="When the DS was last updated") ontology: OntologyModel | None = Field( @@ -227,6 +231,7 @@ def from_domain(cls, ds: DataSource) -> DataSourceResponse: clone_head_commit=ds.clone_head_commit, last_extraction_baseline_commit=ds.last_extraction_baseline_commit, tracked_branch_head_commit=ds.tracked_branch_head_commit, + connection_config=dict(ds.connection_config), created_at=ds.created_at, updated_at=ds.updated_at, ontology=( @@ -471,6 +476,10 @@ class DataSourceWithSyncResponse(BaseModel): tracked_branch_head_commit: str | None = Field( None, description="Latest known commit at the tracked source branch head" ) + connection_config: dict[str, str] = Field( + default_factory=dict, + description="Adapter connection configuration (non-secret)", + ) created_at: datetime = Field(..., description="When the DS was created") updated_at: datetime = Field(..., description="When the DS was last updated") ontology: OntologyModel | None = Field( @@ -506,6 +515,7 @@ def from_domain_pair( clone_head_commit=ds.clone_head_commit, last_extraction_baseline_commit=ds.last_extraction_baseline_commit, tracked_branch_head_commit=ds.tracked_branch_head_commit, + connection_config=dict(ds.connection_config), created_at=ds.created_at, updated_at=ds.updated_at, ontology=( diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index 4610ec3ca..ea67a40af 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -99,7 +99,6 @@ async def refresh_commit_references( user_id=current_user.user_id.value, ds_id=ds_id, tracked_branch_head_commit=tracked_head, - clone_head_commit=tracked_head, ) return DataSourceResponse.from_domain(updated) except UnauthorizedError: diff --git a/src/api/tests/unit/management/application/test_data_source_service.py b/src/api/tests/unit/management/application/test_data_source_service.py index 973528875..a55da66be 100644 --- a/src/api/tests/unit/management/application/test_data_source_service.py +++ b/src/api/tests/unit/management/application/test_data_source_service.py @@ -1158,7 +1158,6 @@ async def test_refresh_commit_references_requires_manage_permission( user_id=user_id, ds_id=ds.id.value, tracked_branch_head_commit="abc123", - clone_head_commit="abc123", ) authz.assert_check_called_once( @@ -1168,12 +1167,13 @@ async def test_refresh_commit_references_requires_manage_permission( ) @pytest.mark.asyncio - async def test_refresh_commit_references_initializes_baseline_when_empty( + async def test_refresh_commit_references_updates_tracked_head_only( self, service, authz, ds_repo, user_id ) -> None: - """First commit-refresh should initialize extraction baseline.""" + """Refresh should update tracked head without touching baseline or clone.""" ds = _make_ds() ds.last_extraction_baseline_commit = None + ds.clone_head_commit = "legacy-clone" ds_repo.seed(ds) authz.grant_all() @@ -1181,12 +1181,11 @@ async def test_refresh_commit_references_initializes_baseline_when_empty( user_id=user_id, ds_id=ds.id.value, tracked_branch_head_commit="abc123", - clone_head_commit="abc123", ) assert updated.tracked_branch_head_commit == "abc123" - assert updated.clone_head_commit == "abc123" - assert updated.last_extraction_baseline_commit == "abc123" + assert updated.clone_head_commit == "legacy-clone" + assert updated.last_extraction_baseline_commit is None @pytest.mark.asyncio async def test_refresh_commit_references_preserves_existing_baseline( @@ -1202,7 +1201,6 @@ async def test_refresh_commit_references_preserves_existing_baseline( user_id=user_id, ds_id=ds.id.value, tracked_branch_head_commit="tracked999", - clone_head_commit="tracked999", ) assert updated.last_extraction_baseline_commit == "baseline000" diff --git a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py index bd9b9a9a5..624beadc4 100644 --- a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py +++ b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py @@ -402,6 +402,8 @@ async def test_mutations_applied_updates_data_source_last_sync_at( credentials_path=None, schedule=Schedule(schedule_type=ScheduleType.MANUAL), last_sync_at=None, + tracked_branch_head_commit="processed-head", + last_extraction_baseline_commit="old-baseline", created_at=now, updated_at=now, ) @@ -419,6 +421,7 @@ async def test_mutations_applied_updates_data_source_last_sync_at( mock_ds_repo.save.assert_called_once() saved_ds = mock_ds_repo.save.call_args[0][0] assert saved_ds.last_sync_at is not None + assert saved_ds.last_extraction_baseline_commit == "processed-head" async def test_mutations_applied_logs_no_changes_short_circuit( self, diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index ec92b4784..52a92ec82 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -1104,13 +1104,13 @@ def test_refresh_commit_references_returns_updated_data_source( test_client: TestClient, mock_ds_service: AsyncMock, mock_commit_reference_service: AsyncMock, + mock_current_user: CurrentUser, sample_data_source: DataSource, ) -> None: """Refresh endpoint should return updated commit references.""" refreshed = sample_data_source - refreshed.clone_head_commit = "aaa" refreshed.tracked_branch_head_commit = "aaa" - refreshed.last_extraction_baseline_commit = "aaa" + refreshed.last_extraction_baseline_commit = None mock_ds_service.get.return_value = sample_data_source mock_commit_reference_service.resolve_tracked_head_commit.return_value = "aaa" mock_ds_service.refresh_commit_references.return_value = refreshed @@ -1121,9 +1121,13 @@ def test_refresh_commit_references_returns_updated_data_source( assert response.status_code == status.HTTP_200_OK payload = response.json() - assert payload["clone_head_commit"] == "aaa" assert payload["tracked_branch_head_commit"] == "aaa" - assert payload["last_extraction_baseline_commit"] == "aaa" + assert payload["last_extraction_baseline_commit"] is None + mock_ds_service.refresh_commit_references.assert_awaited_once_with( + user_id=mock_current_user.user_id.value, + ds_id=sample_data_source.id.value, + tracked_branch_head_commit="aaa", + ) def test_refresh_commit_references_returns_404_when_inaccessible( self, diff --git a/src/dev-ui/app/pages/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue index 537e702ea..6aae7709c 100644 --- a/src/dev-ui/app/pages/data-sources/index.vue +++ b/src/dev-ui/app/pages/data-sources/index.vue @@ -101,7 +101,6 @@ interface DataSourceItem { knowledge_graph_id: string last_sync_at: string | null created_at: string - clone_head_commit?: string | null last_extraction_baseline_commit?: string | null tracked_branch_head_commit?: string | null sync_runs?: SyncRun[] @@ -1253,11 +1252,7 @@ async function handleDeleteDs() { <!-- Commit status and diff summary cues --> <div class="border-t px-4 py-3"> <p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Commit Status</p> - <div class="grid gap-2 sm:grid-cols-3"> - <div class="rounded-md border bg-muted/20 p-2"> - <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Local clone commit</p> - <p class="mt-1 font-mono text-xs break-all">{{ ds.clone_head_commit ?? '—' }}</p> - </div> + <div class="grid gap-2 sm:grid-cols-2"> <div class="rounded-md border bg-muted/20 p-2"> <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Commit during last extraction</p> <p class="mt-1 font-mono text-xs break-all">{{ ds.last_extraction_baseline_commit ?? '—' }}</p> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue index 614924aac..4f610dd1f 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -4,13 +4,17 @@ import { toast } from 'vue-sonner' import { Cable, ChevronLeft, + GitBranch, Plus, - Loader2, Trash2, + Loader2, + Check, + ArrowRight, Settings, RefreshCw, ScrollText, Building2, + LayoutDashboard, } from 'lucide-vue-next' import { buildKgDataSourcesNewUrl, @@ -18,7 +22,27 @@ import { parseKgDataSourcesFocusQuery, } from '@/utils/kgDataSourcesNavigation' import { isMaintenanceReady } from '@/utils/kgManageWorkspace' -import { hasAnyActiveSync, type SyncRunStatus } from '@/utils/kgDataSourcesSync' +import { + hasAnyActiveSync, + isSyncTerminal, + latestSyncRun, + type SyncRunStatus, +} from '@/utils/kgDataSourcesSync' +import { + commitStatusClass, + commitStatusLabel, + prepStatusBadgeVariant, + resolvePrepStatusLabel, + resolveRepoUrl, + resolveTrackedBranch, + shortCommitHash, +} from '@/utils/kgDataSourcesCommits' +import { + buildDataSourceCreationBody, + buildDataSourceCreationUrl, + detectAdapterFromUrl, + inferNameFromRepoUrl, +} from '@/utils/dataSourceWizard' import SyncPhaseIndicator from '@/components/graph/SyncPhaseIndicator.vue' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -26,7 +50,6 @@ import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { CopyableText } from '@/components/ui/copyable-text' import { Sheet, SheetContent, @@ -73,7 +96,7 @@ interface DataSourceItem { name: string adapter_type: string knowledge_graph_id: string - clone_head_commit?: string | null + connection_config?: Record<string, string> last_extraction_baseline_commit?: string | null tracked_branch_head_commit?: string | null sync_runs?: SyncRun[] @@ -92,16 +115,43 @@ const dataSources = ref<DataSourceItem[]>([]) const loading = ref(false) const expandedDiffLists = ref<Record<string, boolean>>({}) const refreshingCommitRefs = ref<Record<string, boolean>>({}) +const refreshingAllCommits = ref(false) const adoptingBaselines = ref<Record<string, boolean>>({}) +const newUrls = ref<string[]>(['']) +const addToken = ref('') +const addingUrls = ref(false) + const manageUrl = computed(() => buildKgManageUrl(kgId.value)) const newSourceUrl = computed(() => buildKgDataSourcesNewUrl(kgId.value)) +const graphManagementUrl = computed( + () => `${buildKgManageUrl(kgId.value)}?step=graph-management`, +) const visibleDataSources = computed(() => { if (!maintainFocus.value) return dataSources.value return dataSources.value.filter((ds) => isMaintenanceReady(ds)) }) +const validNewUrls = computed(() => + newUrls.value + .map((url) => url.trim()) + .filter((url) => url.startsWith('http://') || url.startsWith('https://') || url.startsWith('git@')), +) + +const preparedCount = computed(() => + dataSources.value.filter((ds) => { + const status = latestSyncRun(ds.sync_runs)?.status + return status === 'ingested' || status === 'completed' + }).length, +) + +const allSourcesPrepared = computed( + () => + dataSources.value.length > 0 + && preparedCount.value === dataSources.value.length, +) + const pollInterval = ref<ReturnType<typeof setInterval> | null>(null) function stopPolling() { @@ -121,6 +171,95 @@ function startPolling() { }, 3000) } +function addUrlField() { + newUrls.value.push('') +} + +function removeUrlField(index: number) { + newUrls.value.splice(index, 1) + if (newUrls.value.length === 0) { + newUrls.value.push('') + } +} + +function updateUrl(index: number, value: string) { + newUrls.value[index] = value +} + +async function detectDefaultBranch(url: string): Promise<string> { + try { + const parsed = new URL(url) + const [owner, repoRaw] = parsed.pathname.split('/').filter(Boolean) + const repo = repoRaw?.replace(/\.git$/, '') + if (!owner || !repo) return 'main' + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`) + if (!response.ok) return 'main' + const payload = (await response.json()) as { default_branch?: string } + return payload.default_branch ?? 'main' + } catch { + return 'main' + } +} + +async function addRepositories() { + if (validNewUrls.value.length === 0) { + toast.error('Please enter at least one valid URL') + return + } + + addingUrls.value = true + const seen = new Set<string>() + let added = 0 + + try { + for (const url of validNewUrls.value) { + if (seen.has(url)) continue + seen.add(url) + + const adapterId = detectAdapterFromUrl(url) + if (adapterId !== 'github') { + toast.error('Unsupported repository URL', { description: url }) + continue + } + + const branch = await detectDefaultBranch(url) + const name = inferNameFromRepoUrl(url) || 'repository' + + try { + await apiFetch(buildDataSourceCreationUrl(kgId.value), { + method: 'POST', + body: buildDataSourceCreationBody({ + name, + adapter_type: 'github', + connection_config: { + repo_url: url, + branch, + }, + credentials: addToken.value.trim() + ? { access_token: addToken.value.trim() } + : undefined, + }), + }) + added += 1 + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to add source' + toast.error(`Failed to add ${url}`, { description: msg }) + } + } + + if (added > 0) { + newUrls.value = [''] + addToken.value = '' + toast.success(`Added ${added} source${added === 1 ? '' : 's'}`, { + description: 'Refresh commits or prepare ingestion context when ready.', + }) + await loadDataSources() + } + } finally { + addingUrls.value = false + } +} + async function loadKnowledgeGraph() { try { const result = await apiFetch<{ name: string }>( @@ -174,6 +313,10 @@ async function ensureEntryRoute() { } } +function latestStatus(ds: DataSourceItem): SyncRunStatus | undefined { + return latestSyncRun(ds.sync_runs)?.status +} + function isDiffExpanded(dsId: string): boolean { return expandedDiffLists.value[dsId] === true } @@ -182,10 +325,13 @@ function toggleDiffExpanded(dsId: string) { expandedDiffLists.value[dsId] = !isDiffExpanded(dsId) } -async function triggerSync(dsId: string) { +async function triggerSync(dsId: string, mode: 'full' | 'ingest_only' = 'full') { try { - await apiFetch(`/management/data-sources/${dsId}/sync`, { method: 'POST' }) - toast.success('Sync triggered') + await apiFetch(`/management/data-sources/${dsId}/sync`, { + method: 'POST', + body: mode === 'ingest_only' ? { mode: 'ingest_only' } : undefined, + }) + toast.success(mode === 'ingest_only' ? 'Preparation started' : 'Sync triggered') await loadDataSources() if (hasAnyActiveSync(dataSources.value)) startPolling() } catch { @@ -206,6 +352,24 @@ async function refreshCommitRefs(dsId: string) { } } +async function refreshAllCommitRefs() { + if (visibleDataSources.value.length === 0) return + refreshingAllCommits.value = true + try { + await Promise.allSettled( + visibleDataSources.value.map((ds) => + apiFetch(`/management/data-sources/${ds.id}/commit-refs/refresh`, { method: 'POST' }), + ), + ) + toast.success('Commit references refreshed') + await loadDataSources() + } catch { + toast.error('Failed to refresh commit references') + } finally { + refreshingAllCommits.value = false + } +} + async function adoptTrackedHeadBaseline(dsId: string) { adoptingBaselines.value[dsId] = true try { @@ -339,32 +503,32 @@ watch(tenantVersion, async () => { </script> <template> - <div class="mx-auto max-w-5xl space-y-6"> - <div class="flex flex-wrap items-center justify-between gap-3"> - <NuxtLink - :to="manageUrl" - class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" - > - <ChevronLeft class="mr-1 size-4" /> - Back to workspace overview - </NuxtLink> - <Button :disabled="!hasTenant" @click="navigateTo(newSourceUrl)"> - <Plus class="mr-2 size-4" /> - Add data source - </Button> - </div> + <div class="mx-auto max-w-7xl space-y-6"> + <NuxtLink + :to="manageUrl" + class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" + > + <ChevronLeft class="mr-1 size-4" /> + Back to workspace overview + </NuxtLink> <div class="flex items-center gap-3"> <div class="rounded-lg bg-primary/10 p-2"> <Cable class="size-5 text-primary" /> </div> - <div> + <div class="min-w-0 flex-1"> <h1 class="text-2xl font-semibold tracking-tight">Data Sources</h1> <p class="text-sm text-muted-foreground"> <template v-if="kgName">{{ kgName }} — </template> Manage connected repositories, sync runs, and commit tracking. </p> </div> + <div v-if="allSourcesPrepared" class="ml-auto shrink-0"> + <Badge variant="success"> + <Check class="mr-1 size-3" /> + Ready + </Badge> + </div> </div> <Separator /> @@ -374,192 +538,308 @@ watch(tenantVersion, async () => { <p class="font-medium">No tenant selected</p> </div> - <div v-else-if="loading" class="flex justify-center py-16"> + <div v-else-if="loading" class="flex justify-center py-12"> <Loader2 class="size-8 animate-spin text-muted-foreground" /> </div> <template v-else> - <Card v-if="maintainFocus"> - <CardHeader class="pb-2"> - <CardTitle class="text-sm">Maintenance focus</CardTitle> - <CardDescription class="text-xs"> - Showing sources with new commits since the last extraction baseline. + <Card> + <CardHeader> + <CardTitle class="flex items-center gap-2 text-base"> + <Plus class="size-4" /> + Add repositories + </CardTitle> + <CardDescription> + Paste Git URLs (HTTPS or <span class="font-mono text-xs">git@</span>). Private repos need a token below. </CardDescription> </CardHeader> + <CardContent class="space-y-3"> + <div v-for="(url, index) in newUrls" :key="index" class="flex items-center gap-2"> + <input + :value="url" + type="text" + class="flex h-9 flex-1 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + placeholder="https://github.com/org/repo" + @input="updateUrl(index, ($event.target as HTMLInputElement).value)" + @keyup.enter="addRepositories" + /> + <Button + variant="ghost" + size="icon" + :disabled="newUrls.length === 1 && !newUrls[0]" + @click="removeUrlField(index)" + > + <Trash2 class="size-4" /> + </Button> + </div> + <div class="space-y-1.5"> + <Label class="text-xs text-muted-foreground">GitHub access token (optional, for new private repos)</Label> + <Input v-model="addToken" type="password" placeholder="ghp_…" autocomplete="off" /> + </div> + <div class="flex flex-wrap items-center gap-2"> + <Button variant="outline" size="sm" @click="addUrlField"> + <Plus class="mr-2 size-4" /> + Add another + </Button> + <Button size="sm" :disabled="addingUrls || validNewUrls.length === 0" @click="addRepositories"> + <Loader2 v-if="addingUrls" class="mr-2 size-4 animate-spin" /> + Add to project + </Button> + </div> + </CardContent> </Card> - <div - v-if="visibleDataSources.length === 0" - class="flex flex-col items-center gap-4 py-16 text-center" - > - <p class="text-sm text-muted-foreground"> - <template v-if="maintainFocus"> - No sources need maintenance right now. - </template> - <template v-else> - No data sources connected. - </template> - </p> - <Button v-if="!maintainFocus" @click="navigateTo(newSourceUrl)"> - <Plus class="mr-2 size-4" /> - Add your first data source - </Button> - </div> - - <div v-else id="maintain-section" class="space-y-3"> - <div - v-for="ds in visibleDataSources" - :key="ds.id" - class="rounded-lg border bg-card" - :class="isMaintenanceReady(ds) ? 'border-amber-300/60' : ''" - > - <div class="flex flex-wrap items-center justify-between gap-3 p-4"> - <div class="flex items-center gap-3"> - <div class="rounded-md bg-muted p-2"> - <Cable class="size-4 text-muted-foreground" /> - </div> + <div v-if="dataSources.length > 0" id="maintain-section" class="space-y-4"> + <Card v-if="maintainFocus" class="border-amber-300/50"> + <CardHeader class="pb-2"> + <CardTitle class="text-sm">Maintenance focus</CardTitle> + <CardDescription class="text-xs"> + Showing sources with new commits since the last extraction baseline. + </CardDescription> + </CardHeader> + </Card> + + <Card class="border-border/80 bg-muted/15"> + <CardHeader class="pb-2"> + <div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div> - <p class="text-sm font-medium">{{ ds.name }}</p> - <p class="text-xs text-muted-foreground">{{ ds.adapter_type }}</p> - <CopyableText :text="ds.id" label="Data source ID copied" class="mt-0.5" /> - </div> - </div> - <div class="flex flex-wrap items-center gap-2"> - <SyncPhaseIndicator - v-if="ds.sync_runs?.[0]" - :status="ds.sync_runs[0].status" - /> - <Badge v-else variant="secondary" class="text-[10px]">Idle</Badge> - <Button size="sm" variant="outline" @click="openEditConfig(ds)"> - <Settings class="mr-1.5 size-3.5" /> - Edit Config - </Button> - <Button - size="sm" - variant="outline" - class="text-destructive hover:bg-destructive/10" - @click="openDeleteDs(ds)" - > - <Trash2 class="mr-1.5 size-3.5" /> - Delete - </Button> - <Button size="sm" variant="outline" @click="triggerSync(ds.id)"> - Sync Now - </Button> - </div> - </div> - - <div class="border-t px-4 py-3"> - <p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> - Commit Status - </p> - <div class="grid gap-2 sm:grid-cols-3"> - <div class="rounded-md border bg-muted/20 p-2"> - <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Local clone commit</p> - <p class="mt-1 break-all font-mono text-xs">{{ ds.clone_head_commit ?? '—' }}</p> + <CardTitle class="flex items-center gap-2 text-base"> + <GitBranch class="size-4 text-primary" /> + Data sources overview + </CardTitle> </div> - <div class="rounded-md border bg-muted/20 p-2"> - <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Last extraction baseline</p> - <p class="mt-1 break-all font-mono text-xs">{{ ds.last_extraction_baseline_commit ?? '—' }}</p> - </div> - <div class="rounded-md border bg-muted/20 p-2"> - <p class="text-[10px] uppercase tracking-wider text-muted-foreground">Tracked branch head</p> - <p class="mt-1 break-all font-mono text-xs">{{ ds.tracked_branch_head_commit ?? '—' }}</p> + <div class="flex flex-wrap gap-2"> + <Button + variant="outline" + size="sm" + :disabled="refreshingAllCommits || visibleDataSources.length === 0" + @click="refreshAllCommitRefs" + > + <Loader2 v-if="refreshingAllCommits" class="mr-2 size-4 animate-spin" /> + <RefreshCw v-else class="mr-2 size-4" /> + Refresh commits + </Button> </div> </div> - <div class="mt-2 flex flex-wrap gap-2"> - <Button - size="sm" - variant="outline" - class="h-7 text-[10px]" - :disabled="refreshingCommitRefs[ds.id] === true" - @click="refreshCommitRefs(ds.id)" - > - <RefreshCw - class="mr-1 size-3" - :class="refreshingCommitRefs[ds.id] ? 'animate-spin' : ''" - /> - Refresh commits - </Button> - <Button - size="sm" - variant="outline" - class="h-7 text-[10px]" - :disabled="adoptingBaselines[ds.id] === true || !isMaintenanceReady(ds)" - @click="adoptTrackedHeadBaseline(ds.id)" - > - Adopt tracked head as baseline - </Button> - </div> - + <CardDescription> + Each row is one connected repository. Compare extraction baseline to tracked branch head + to see when maintenance syncs are needed. + </CardDescription> + </CardHeader> + <CardContent class="space-y-3"> <div - v-if="ds.diff_summary" - class="mt-3 rounded-md border p-2" - :class="isMaintenanceReady(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" + v-if="visibleDataSources.length === 0" + class="rounded-md border bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground" > - <div class="flex items-center justify-between gap-2 text-xs"> - <span> - <span class="font-medium">{{ ds.diff_summary.total_changed_files }}</span> - changed files - </span> - <Badge - :variant="isMaintenanceReady(ds) ? 'default' : 'secondary'" - class="text-[10px]" - > - {{ isMaintenanceReady(ds) ? 'New commits available' : 'Up to date' }} - </Badge> - </div> - <Button - v-if="ds.diff_summary.changed_files.length > 0" - size="sm" - variant="ghost" - class="mt-2 h-6 px-2 text-[10px]" - @click="toggleDiffExpanded(ds.id)" - > - {{ isDiffExpanded(ds.id) ? 'Hide changed files' : 'Show changed files' }} - </Button> - <div - v-if="isDiffExpanded(ds.id)" - class="mt-2 max-h-48 space-y-1 overflow-y-auto rounded-md border bg-background/80 p-2" - > - <div - v-for="file in ds.diff_summary.changed_files" - :key="`${file.status}:${file.path}`" - class="flex justify-between gap-2 text-[11px]" - > - <span class="break-all font-mono">{{ file.path }}</span> - <Badge variant="outline" class="h-5 text-[10px] uppercase">{{ file.status }}</Badge> - </div> - </div> + <template v-if="maintainFocus">No sources need maintenance right now.</template> + <template v-else>No data sources to display.</template> </div> - </div> - <div v-if="ds.sync_runs?.length" class="border-t px-4 py-3"> - <p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> - Sync History - </p> - <div class="space-y-1"> - <div - v-for="run in ds.sync_runs" - :key="run.id" - class="flex items-center gap-2 text-xs text-muted-foreground" - > - <SyncPhaseIndicator :status="run.status" /> - <span>{{ new Date(run.started_at).toLocaleString() }}</span> - <span v-if="run.error" class="text-destructive">{{ run.error }}</span> - <Button - size="sm" - variant="ghost" - class="ml-auto h-6 px-2 text-[10px]" - @click="viewLogs(ds, run)" - > - <ScrollText class="mr-1 size-3" /> - View Logs - </Button> - </div> + <div v-else class="overflow-x-auto rounded-md border"> + <table class="w-full min-w-[880px] text-sm"> + <thead> + <tr class="border-b bg-muted/50 text-left"> + <th class="px-3 py-2 font-medium">Source</th> + <th class="px-3 py-2 font-medium">Branch</th> + <th class="px-3 py-2 font-medium">Status</th> + <th class="px-3 py-2 font-medium">Last extraction baseline</th> + <th class="px-3 py-2 font-medium">Tracked branch head</th> + <th class="px-3 py-2 font-medium">Actions</th> + </tr> + </thead> + <tbody> + <tr + v-for="ds in visibleDataSources" + :key="ds.id" + class="border-b border-border/60 align-top last:border-0" + :class="isMaintenanceReady(ds) ? 'bg-amber-50/40 dark:bg-amber-950/10' : ''" + > + <td class="px-3 py-2"> + <p class="font-medium leading-tight">{{ ds.name }}</p> + <p + class="mt-0.5 max-w-[20rem] truncate font-mono text-xs text-muted-foreground" + :title="resolveRepoUrl(ds.connection_config)" + > + {{ resolveRepoUrl(ds.connection_config) }} + </p> + </td> + <td class="px-3 py-2 font-mono text-xs"> + {{ resolveTrackedBranch(ds.connection_config) }} + </td> + <td class="px-3 py-2"> + <Badge :variant="prepStatusBadgeVariant(latestStatus(ds))" class="text-xs"> + {{ resolvePrepStatusLabel(latestStatus(ds)) }} + </Badge> + <div v-if="latestStatus(ds) && !isSyncTerminal(latestStatus(ds))" class="mt-1"> + <SyncPhaseIndicator :status="latestStatus(ds)!" /> + </div> + </td> + <td class="px-3 py-2 font-mono text-xs"> + <div :class="commitStatusClass(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit)"> + <span :title="ds.last_extraction_baseline_commit || ''"> + {{ shortCommitHash(ds.last_extraction_baseline_commit) }} + </span> + </div> + <div + class="mt-0.5 text-[10px]" + :class="commitStatusClass(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit)" + > + {{ commitStatusLabel(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit) }} + </div> + </td> + <td class="px-3 py-2 font-mono text-xs"> + <span :title="ds.tracked_branch_head_commit || ''"> + {{ shortCommitHash(ds.tracked_branch_head_commit) }} + </span> + </td> + <td class="px-3 py-2"> + <div class="flex flex-wrap gap-1"> + <Button size="sm" variant="ghost" class="h-7 px-2 text-[10px]" @click="openEditConfig(ds)"> + <Settings class="mr-1 size-3" /> + Edit + </Button> + <Button + size="sm" + variant="ghost" + class="h-7 px-2 text-[10px]" + :disabled="refreshingCommitRefs[ds.id] === true" + @click="refreshCommitRefs(ds.id)" + > + <RefreshCw class="mr-1 size-3" :class="refreshingCommitRefs[ds.id] ? 'animate-spin' : ''" /> + Refresh + </Button> + <Button + size="sm" + variant="ghost" + class="h-7 px-2 text-[10px]" + :disabled="!isMaintenanceReady(ds) || adoptingBaselines[ds.id] === true" + @click="adoptTrackedHeadBaseline(ds.id)" + > + Adopt baseline + </Button> + <Button + size="sm" + variant="ghost" + class="h-7 px-2 text-[10px]" + @click="triggerSync(ds.id, 'ingest_only')" + > + Prepare + </Button> + <Button + size="sm" + variant="ghost" + class="h-7 px-2 text-[10px] text-destructive" + @click="openDeleteDs(ds)" + > + <Trash2 class="mr-1 size-3" /> + Delete + </Button> + </div> + + <div + v-if="ds.diff_summary && ds.diff_summary.total_changed_files > 0" + class="mt-2 rounded border p-2 text-[11px]" + :class="isMaintenanceReady(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" + > + <div class="flex items-center justify-between gap-2"> + <span> + <span class="font-medium">{{ ds.diff_summary.total_changed_files }}</span> + changed files + </span> + <Badge :variant="isMaintenanceReady(ds) ? 'default' : 'secondary'" class="text-[10px]"> + {{ isMaintenanceReady(ds) ? 'New commits available' : 'Up to date' }} + </Badge> + </div> + <Button + size="sm" + variant="ghost" + class="mt-1 h-6 px-2 text-[10px]" + @click="toggleDiffExpanded(ds.id)" + > + {{ isDiffExpanded(ds.id) ? 'Hide files' : 'Show files' }} + </Button> + <div + v-if="isDiffExpanded(ds.id)" + class="mt-1 max-h-32 space-y-1 overflow-y-auto" + > + <div + v-for="file in ds.diff_summary.changed_files" + :key="`${file.status}:${file.path}`" + class="flex justify-between gap-2 font-mono" + > + <span class="break-all">{{ file.path }}</span> + <Badge variant="outline" class="h-5 text-[10px] uppercase">{{ file.status }}</Badge> + </div> + </div> + </div> + + <div v-if="ds.sync_runs?.length" class="mt-2 space-y-1"> + <div + v-for="run in ds.sync_runs.slice(0, 2)" + :key="run.id" + class="flex items-center gap-2 text-[10px] text-muted-foreground" + > + <SyncPhaseIndicator :status="run.status" /> + <span>{{ new Date(run.started_at).toLocaleString() }}</span> + <Button + size="sm" + variant="ghost" + class="ml-auto h-5 px-1" + @click="viewLogs(ds, run)" + > + <ScrollText class="mr-1 size-3" /> + Logs + </Button> + </div> + </div> + </td> + </tr> + </tbody> + </table> </div> - </div> - </div> + </CardContent> + </Card> + </div> + + <Card v-if="allSourcesPrepared" class="border-primary/40"> + <CardHeader> + <CardTitle class="flex items-center gap-2 text-base text-green-600 dark:text-green-400"> + <Check class="size-5" /> + Data Sources ready + </CardTitle> + <CardDescription> + {{ preparedCount }} of {{ dataSources.length }} source{{ dataSources.length === 1 ? '' : 's' }} + prepared for graph management and extraction. + </CardDescription> + </CardHeader> + <CardContent class="flex flex-col gap-4 sm:flex-row sm:flex-wrap"> + <p class="w-full text-sm text-muted-foreground"> + Ingestion context is prepared. Open graph management to design schema, run extraction, + or continue in the manage workspace. + </p> + <Button as-child> + <NuxtLink :to="graphManagementUrl" class="inline-flex items-center gap-2"> + Open Graph Management + <ArrowRight class="size-4" /> + </NuxtLink> + </Button> + <Button as-child variant="outline"> + <NuxtLink :to="manageUrl" class="inline-flex items-center gap-2"> + <LayoutDashboard class="size-4" /> + Back to workspace overview + </NuxtLink> + </Button> + </CardContent> + </Card> + + <div + v-if="!allSourcesPrepared && dataSources.length === 0" + class="rounded-lg border bg-muted/50 p-4" + > + <p class="text-sm text-muted-foreground"> + <strong>Flow:</strong> add repository URLs above, refresh commits to resolve branch heads, + then prepare ingestion context before opening graph management. + </p> </div> </template> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue index 8a5344106..331d775e6 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue @@ -314,18 +314,25 @@ async function refreshSourceSyncStatus(row: CreatedSourceRow) { } } -async function pollUntilTerminal(row: CreatedSourceRow, timeoutMs = 600_000) { +async function pollUntilAllTerminal(rows: CreatedSourceRow[], timeoutMs = 600_000) { const started = Date.now() while (Date.now() - started < timeoutMs) { - await refreshSourceSyncStatus(row) - if (isSyncTerminal(row.syncStatus as SyncRunStatus)) return - await new Promise((resolve) => setTimeout(resolve, 3000)) + await Promise.all(rows.map((row) => refreshSourceSyncStatus(row))) + const finished = rows.filter((row) => isSyncTerminal(row.syncStatus as SyncRunStatus)).length + syncCompletedInRun.value = finished + syncStepLabel.value = `${finished} / ${rows.length}` + if (finished === rows.length) return + await new Promise((resolve) => setTimeout(resolve, 1500)) + } + for (const row of rows) { + if (!isSyncTerminal(row.syncStatus as SyncRunStatus)) { + row.syncStatus = 'failed' + row.syncError = 'Sync timed out' + } } - row.syncStatus = 'failed' - row.syncError = 'Sync timed out' } -async function runSequentialIngestionPrep() { +async function runParallelIngestionPrep() { const queue = createdSources.value.filter( (s) => s.syncStatus === 'idle' || s.syncStatus === 'failed' || s.syncStatus === 'queued', ) @@ -338,33 +345,37 @@ async function runSequentialIngestionPrep() { syncRunTotal.value = queue.length syncCompletedInRun.value = 0 readyForStats.value = false + syncStepLabel.value = `0 / ${queue.length}` + syncActiveName.value = `${queue.length} source${queue.length === 1 ? '' : 's'}` - try { - for (let i = 0; i < queue.length; i++) { - const target = queue[i]! - syncStepLabel.value = `${i + 1} / ${queue.length}` - syncActiveName.value = target.name - target.syncStatus = 'pending' - target.syncError = null + for (const target of queue) { + target.syncStatus = 'pending' + target.syncError = null + } - try { - await apiFetch(`/management/data-sources/${target.id}/sync`, { - method: 'POST', - body: { mode: 'ingest_only' }, - }) - await pollUntilTerminal(target) - if (target.syncStatus === 'failed') { - toast.error(`Preparation failed: ${target.name}`, { - description: target.syncError ?? undefined, + try { + await Promise.allSettled( + queue.map(async (target) => { + try { + await apiFetch(`/management/data-sources/${target.id}/sync`, { + method: 'POST', + body: { mode: 'ingest_only' }, }) + } catch (err: unknown) { + target.syncStatus = 'failed' + target.syncError = err instanceof Error ? err.message : 'Preparation failed' } - } catch (err: unknown) { - target.syncStatus = 'failed' - target.syncError = err instanceof Error ? err.message : 'Preparation failed' - toast.error(`Preparation failed: ${target.name}`, { description: target.syncError }) - } + }), + ) - syncCompletedInRun.value = i + 1 + await pollUntilAllTerminal(queue) + + for (const target of queue) { + if (target.syncStatus === 'failed') { + toast.error(`Preparation failed: ${target.name}`, { + description: target.syncError ?? undefined, + }) + } } const allPrepared = createdSources.value.every((s) => s.syncStatus === 'ingested') @@ -562,8 +573,7 @@ onUnmounted(() => { <CardTitle class="text-base">Prepare ingestion context</CardTitle> <CardDescription> Fetch repository content and build job packages for each source. No AI extraction - runs here — that happens later in graph management. Sources are prepared one at a - time so you can follow progress. + runs here — that happens later in graph management. Sources are prepared in parallel. </CardDescription> </CardHeader> <CardContent class="space-y-4"> @@ -572,7 +582,7 @@ onUnmounted(() => { type="button" size="sm" :disabled="syncRunActive || createdSources.length === 0" - @click="runSequentialIngestionPrep" + @click="runParallelIngestionPrep" > <Loader2 v-if="syncRunActive" class="mr-2 size-4 animate-spin" /> <GitBranch v-if="!syncRunActive" class="mr-2 size-4" /> diff --git a/src/dev-ui/app/tests/data-sources.test.ts b/src/dev-ui/app/tests/data-sources.test.ts index be978914a..f576c0cb3 100644 --- a/src/dev-ui/app/tests/data-sources.test.ts +++ b/src/dev-ui/app/tests/data-sources.test.ts @@ -3211,9 +3211,9 @@ describe('Commit-hash status cues - structural verification', () => { it('renders commit status section with canonical commit labels', () => { expect(source).toContain('Commit Status') - expect(source).toContain('Local clone commit') expect(source).toContain('Commit during last extraction') expect(source).toContain('Tracked branch head commit') + expect(source).not.toContain('Local clone commit') }) it('renders visual readiness cue when new commits exist', () => { diff --git a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts new file mode 100644 index 000000000..68837d47d --- /dev/null +++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { + commitStatusLabel, + prepStatusBadgeVariant, + resolvePrepStatusLabel, + resolveRepoUrl, + shortCommitHash, +} from '@/utils/kgDataSourcesCommits' + +const phase1Vue = readFileSync( + resolve(__dirname, '../pages/knowledge-graphs/[kgId]/data-sources/index.vue'), + 'utf-8', +) + +const newVue = readFileSync( + resolve(__dirname, '../pages/knowledge-graphs/[kgId]/data-sources/new.vue'), + 'utf-8', +) + +describe('KG data sources phase1 layout', () => { + it('uses wide page container like k-extract phase1', () => { + expect(phase1Vue).toContain('max-w-7xl') + }) + + it('renders add repositories and overview sections', () => { + expect(phase1Vue).toContain('Add repositories') + expect(phase1Vue).toContain('Data sources overview') + expect(phase1Vue).toContain('Add to project') + }) + + it('renders data sources ready footer with graph management link', () => { + expect(phase1Vue).toContain('Data Sources ready') + expect(phase1Vue).toContain('Open Graph Management') + expect(phase1Vue).toContain('step=graph-management') + }) + + it('does not render legacy per-card commit status layout', () => { + expect(phase1Vue).not.toContain('Commit Status') + expect(phase1Vue).not.toContain('Local clone commit') + }) +}) + +describe('KG wizard parallel ingestion prep', () => { + it('prepares sources in parallel', () => { + expect(newVue).toContain('runParallelIngestionPrep') + expect(newVue).toContain('Promise.allSettled') + expect(newVue).toContain('pollUntilAllTerminal') + expect(newVue).not.toContain('runSequentialIngestionPrep') + }) +}) + +describe('kgDataSourcesCommits helpers', () => { + it('shortens commit hashes for display', () => { + expect(shortCommitHash('abcdef1234567890')).toBe('abcdef123456') + expect(shortCommitHash(null)).toBe('—') + }) + + it('derives repo url from connection config', () => { + expect(resolveRepoUrl({ repo_url: 'https://github.com/org/repo' })).toBe( + 'https://github.com/org/repo', + ) + expect(resolveRepoUrl({ owner: 'org', repo: 'repo', branch: 'dev' })).toContain('/tree/dev') + }) + + it('maps sync statuses to prep labels', () => { + expect(resolvePrepStatusLabel('ingested')).toBe('Prepared') + expect(prepStatusBadgeVariant('ingested')).toBe('success') + expect(commitStatusLabel('abc', 'abc')).toBe('matches branch head') + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 4a770be9b..a7e3b1e24 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -310,6 +310,8 @@ describe('KG-MANAGE-005 - graph-scoped data sources step', () => { ) expect(kgDataSourcesIndex).toContain('Back to workspace overview') expect(kgDataSourcesIndex).toContain('buildKgManageUrl') + expect(kgDataSourcesIndex).toContain('Data sources overview') + expect(kgDataSourcesIndex).toContain('max-w-7xl') }) }) diff --git a/src/dev-ui/app/utils/kgDataSourcesCommits.ts b/src/dev-ui/app/utils/kgDataSourcesCommits.ts new file mode 100644 index 000000000..26ab8ef0c --- /dev/null +++ b/src/dev-ui/app/utils/kgDataSourcesCommits.ts @@ -0,0 +1,78 @@ +import type { SyncRunStatus } from '@/utils/kgDataSourcesSync' + +export function shortCommitHash(hash: string | null | undefined): string { + if (!hash) return '—' + return hash.length > 12 ? hash.slice(0, 12) : hash +} + +export function commitStatusClass( + current: string | null | undefined, + remote: string | null | undefined, +): string { + if (!current || !remote) return 'text-muted-foreground' + return current === remote + ? 'text-green-600 dark:text-green-400' + : 'text-amber-600 dark:text-amber-400' +} + +export function commitStatusLabel( + current: string | null | undefined, + remote: string | null | undefined, +): string { + if (!current || !remote) return 'not set' + return current === remote ? 'matches branch head' : 'new commits on branch' +} + +export function resolveRepoUrl(connectionConfig: Record<string, string> | undefined): string { + if (!connectionConfig) return '—' + if (connectionConfig.repo_url) return connectionConfig.repo_url + if (connectionConfig.owner && connectionConfig.repo) { + const branch = connectionConfig.branch ?? 'main' + return `https://github.com/${connectionConfig.owner}/${connectionConfig.repo}/tree/${branch}` + } + return '—' +} + +export function resolveTrackedBranch(connectionConfig: Record<string, string> | undefined): string { + if (!connectionConfig) return 'main' + return connectionConfig.branch ?? 'main' +} + +export type PrepStatusLabel = 'Prepared' | 'Synced' | 'Preparing' | 'Failed' | 'Not prepared' + +export function resolvePrepStatusLabel(status: SyncRunStatus | undefined): PrepStatusLabel { + switch (status) { + case 'ingested': + return 'Prepared' + case 'completed': + return 'Synced' + case 'failed': + return 'Failed' + case 'pending': + case 'ingesting': + case 'ai_extracting': + case 'applying': + return 'Preparing' + default: + return 'Not prepared' + } +} + +export function prepStatusBadgeVariant( + status: SyncRunStatus | undefined, +): 'success' | 'destructive' | 'secondary' | 'outline' { + switch (status) { + case 'ingested': + case 'completed': + return 'success' + case 'failed': + return 'destructive' + case 'pending': + case 'ingesting': + case 'ai_extracting': + case 'applying': + return 'secondary' + default: + return 'outline' + } +} From e0001b804dc03b90c500e8e3c1d3b5d95154319c Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Thu, 28 May 2026 18:40:29 -0400 Subject: [PATCH 58/80] feat(data-sources): persist prepare metadata and bulk actions on KG page Track last prepared commit and file count during ingest-only runs so the overview can show branch freshness, file totals, and bulk prepare/check flows. Co-authored-by: Cursor <cursoragent@cursor.com> --- ...3l4_add_prepared_fields_to_data_sources.py | 35 ++++ .../application/services/ingestion_service.py | 18 +- .../ingestion/application/value_objects.py | 16 ++ .../ingestion/infrastructure/event_handler.py | 11 +- src/api/ingestion/ports/services.py | 5 +- src/api/main.py | 11 +- .../domain/aggregates/data_source.py | 22 ++ .../infrastructure/models/data_source.py | 2 + .../repositories/data_source_repository.py | 6 + .../infrastructure/sync_lifecycle_handler.py | 33 +++ .../presentation/data_sources/models.py | 16 ++ .../application/test_ingestion_service.py | 18 +- .../test_ingestion_event_handler.py | 11 +- .../test_sync_lifecycle_handler.py | 26 ++- .../tests/unit/management/test_data_source.py | 33 +++ .../unit/test_sessioned_ingestion_handler.py | 66 ++++++ .../[kgId]/data-sources/index.vue | 188 +++++++++--------- .../app/tests/kg-data-sources-phase1.test.ts | 25 ++- src/dev-ui/app/utils/kgDataSourcesCommits.ts | 32 +++ 19 files changed, 455 insertions(+), 119 deletions(-) create mode 100644 src/api/infrastructure/migrations/versions/g9h0i1j2k3l4_add_prepared_fields_to_data_sources.py create mode 100644 src/api/ingestion/application/value_objects.py diff --git a/src/api/infrastructure/migrations/versions/g9h0i1j2k3l4_add_prepared_fields_to_data_sources.py b/src/api/infrastructure/migrations/versions/g9h0i1j2k3l4_add_prepared_fields_to_data_sources.py new file mode 100644 index 000000000..4c2c99c62 --- /dev/null +++ b/src/api/infrastructure/migrations/versions/g9h0i1j2k3l4_add_prepared_fields_to_data_sources.py @@ -0,0 +1,35 @@ +"""add prepared commit and file count columns to data_sources + +Tracks the commit SHA and file count captured during the last ingest-only +prepare run so the UI can show branch freshness and JobPackage scope. + +Revision ID: g9h0i1j2k3l4 +Revises: fc2d3e4f5a6b +Create Date: 2026-05-26 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "g9h0i1j2k3l4" +down_revision: Union[str, Sequence[str], None] = "fc2d3e4f5a6b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "data_sources", + sa.Column("last_prepared_commit", sa.String(length=64), nullable=True), + ) + op.add_column( + "data_sources", + sa.Column("last_prepared_file_count", sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("data_sources", "last_prepared_file_count") + op.drop_column("data_sources", "last_prepared_commit") diff --git a/src/api/ingestion/application/services/ingestion_service.py b/src/api/ingestion/application/services/ingestion_service.py index 489a10ef4..293554c78 100644 --- a/src/api/ingestion/application/services/ingestion_service.py +++ b/src/api/ingestion/application/services/ingestion_service.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from ingestion.application.value_objects import IngestionRunResult from ingestion.ports.adapters import IDatasourceAdapter if TYPE_CHECKING: @@ -16,7 +17,6 @@ from shared_kernel.job_package.builder import JobPackageBuilder from shared_kernel.job_package.value_objects import ( AdapterCheckpoint, - JobPackageId, SyncMode, ) @@ -61,7 +61,7 @@ async def run( tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, - ) -> JobPackageId: + ) -> IngestionRunResult: """Run the ingestion pipeline for a data source sync. Args: @@ -77,7 +77,7 @@ async def run( incremental extraction checkpoint state Returns: - The JobPackageId of the produced ZIP archive + IngestionRunResult with the produced JobPackage metadata Raises: ValueError: If the adapter_type is not registered @@ -138,4 +138,14 @@ async def run( self._work_dir.mkdir(parents=True, exist_ok=True) builder.build(self._work_dir) - return builder._package_id + prepared_commit_sha = None + if result.new_checkpoint is not None: + prepared_commit_sha = result.new_checkpoint.data.get("commit_sha") + + return IngestionRunResult( + job_package_id=builder._package_id, + entry_count=len(result.changeset_entries), + prepared_commit_sha=( + str(prepared_commit_sha) if prepared_commit_sha is not None else None + ), + ) diff --git a/src/api/ingestion/application/value_objects.py b/src/api/ingestion/application/value_objects.py new file mode 100644 index 000000000..5aa28ec68 --- /dev/null +++ b/src/api/ingestion/application/value_objects.py @@ -0,0 +1,16 @@ +"""Application-layer value objects for the Ingestion bounded context.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from shared_kernel.job_package.value_objects import JobPackageId + + +@dataclass(frozen=True) +class IngestionRunResult: + """Outcome of a successful ingestion pipeline run.""" + + job_package_id: JobPackageId + entry_count: int + prepared_commit_sha: str | None diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index 788920217..052f6b9bc 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -106,6 +106,9 @@ async def handle( "data_source_id": data_source_id, "knowledge_graph_id": knowledge_graph_id, "no_changes_detected": True, + "prepared_commit_sha": payload.get( + "tracked_branch_head_commit" + ), "occurred_at": now.isoformat(), }, occurred_at=now, @@ -129,7 +132,7 @@ async def handle( return try: - job_package_id = await self._ingestion_service.run( + ingestion_result = await self._ingestion_service.run( sync_run_id=sync_run_id, data_source_id=data_source_id, knowledge_graph_id=knowledge_graph_id, @@ -168,7 +171,9 @@ async def handle( "sync_run_id": sync_run_id, "data_source_id": data_source_id, "knowledge_graph_id": knowledge_graph_id, - "job_package_id": str(job_package_id), + "job_package_id": str(ingestion_result.job_package_id), + "prepared_commit_sha": ingestion_result.prepared_commit_sha, + "prepared_file_count": ingestion_result.entry_count, "occurred_at": now.isoformat(), }, occurred_at=now, @@ -182,7 +187,7 @@ async def handle( "sync_run_id": sync_run_id, "data_source_id": data_source_id, "knowledge_graph_id": knowledge_graph_id, - "job_package_id": str(job_package_id), + "job_package_id": str(ingestion_result.job_package_id), "occurred_at": now.isoformat(), }, occurred_at=now, diff --git a/src/api/ingestion/ports/services.py b/src/api/ingestion/ports/services.py index 6aee85417..150ccf224 100644 --- a/src/api/ingestion/ports/services.py +++ b/src/api/ingestion/ports/services.py @@ -4,6 +4,7 @@ from typing import Protocol +from ingestion.application.value_objects import IngestionRunResult from shared_kernel.job_package.value_objects import JobPackageId @@ -25,7 +26,7 @@ async def run( tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, - ) -> JobPackageId: + ) -> IngestionRunResult: """Run the ingestion pipeline. Args: @@ -39,7 +40,7 @@ async def run( baseline_commit: Optional commit SHA used as incremental baseline Returns: - JobPackageId for the produced archive + IngestionRunResult with the produced archive metadata Raises: ValueError: If the adapter_type is unknown diff --git a/src/api/main.py b/src/api/main.py index b45a75cb9..ecdf42dcc 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -249,10 +249,13 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: if data_source_id and adapter_type == "github": ds = await ds_repo.get_by_id(DataSourceId(value=data_source_id)) if ds is not None: - if ds.last_extraction_baseline_commit: - enriched_payload["baseline_commit"] = ( - ds.last_extraction_baseline_commit - ) + pipeline_mode = str(payload.get("pipeline_mode", "full")) + if pipeline_mode == "ingest_only": + baseline_commit = ds.last_prepared_commit + else: + baseline_commit = ds.last_extraction_baseline_commit + if baseline_commit: + enriched_payload["baseline_commit"] = baseline_commit if ds.credentials_path and tenant_id and credential_reader is not None: try: diff --git a/src/api/management/domain/aggregates/data_source.py b/src/api/management/domain/aggregates/data_source.py index e61ee09a5..09fe5aa38 100644 --- a/src/api/management/domain/aggregates/data_source.py +++ b/src/api/management/domain/aggregates/data_source.py @@ -66,6 +66,8 @@ class DataSource: clone_head_commit: str | None = None last_extraction_baseline_commit: str | None = None tracked_branch_head_commit: str | None = None + last_prepared_commit: str | None = None + last_prepared_file_count: int | None = None ontology: Ontology | None = None _pending_events: list[DomainEvent] = field(default_factory=list, repr=False) _probe: DataSourceProbe = field( @@ -378,6 +380,26 @@ def advance_extraction_baseline_to_tracked_head(self) -> None: if self.tracked_branch_head_commit: self.last_extraction_baseline_commit = self.tracked_branch_head_commit + def record_ingestion_prepared( + self, + *, + prepared_commit: str | None, + prepared_file_count: int | None = None, + ) -> None: + """Record the commit and file count from a successful ingest-only prepare. + + Raises: + AggregateDeletedError: If the data source has been marked for deletion + """ + if self._deleted: + raise AggregateDeletedError( + "Cannot record ingestion prepare on a deleted data source" + ) + if prepared_commit: + self.last_prepared_commit = prepared_commit + if prepared_file_count is not None: + self.last_prepared_file_count = prepared_file_count + def mark_for_deletion( self, *, diff --git a/src/api/management/infrastructure/models/data_source.py b/src/api/management/infrastructure/models/data_source.py index c8b5da737..737a68d7d 100644 --- a/src/api/management/infrastructure/models/data_source.py +++ b/src/api/management/infrastructure/models/data_source.py @@ -49,6 +49,8 @@ class DataSourceModel(Base, TimestampMixin): tracked_branch_head_commit: Mapped[str | None] = mapped_column( String(64), nullable=True ) + last_prepared_commit: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_prepared_file_count: Mapped[int | None] = mapped_column(nullable=True) ontology_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) __table_args__ = ( diff --git a/src/api/management/infrastructure/repositories/data_source_repository.py b/src/api/management/infrastructure/repositories/data_source_repository.py index de2f23ff6..f4e650b3c 100644 --- a/src/api/management/infrastructure/repositories/data_source_repository.py +++ b/src/api/management/infrastructure/repositories/data_source_repository.py @@ -85,6 +85,8 @@ async def save(self, data_source: DataSource) -> None: data_source.last_extraction_baseline_commit ) model.tracked_branch_head_commit = data_source.tracked_branch_head_commit + model.last_prepared_commit = data_source.last_prepared_commit + model.last_prepared_file_count = data_source.last_prepared_file_count model.updated_at = data_source.updated_at model.ontology_json = ontology_json else: @@ -104,6 +106,8 @@ async def save(self, data_source: DataSource) -> None: data_source.last_extraction_baseline_commit ), tracked_branch_head_commit=data_source.tracked_branch_head_commit, + last_prepared_commit=data_source.last_prepared_commit, + last_prepared_file_count=data_source.last_prepared_file_count, ontology_json=ontology_json, created_at=data_source.created_at, updated_at=data_source.updated_at, @@ -220,5 +224,7 @@ def _to_domain(self, model: DataSourceModel) -> DataSource: clone_head_commit=model.clone_head_commit, last_extraction_baseline_commit=model.last_extraction_baseline_commit, tracked_branch_head_commit=model.tracked_branch_head_commit, + last_prepared_commit=model.last_prepared_commit, + last_prepared_file_count=model.last_prepared_file_count, ontology=ontology, ) diff --git a/src/api/management/infrastructure/sync_lifecycle_handler.py b/src/api/management/infrastructure/sync_lifecycle_handler.py index 023f42112..97ebe2927 100644 --- a/src/api/management/infrastructure/sync_lifecycle_handler.py +++ b/src/api/management/infrastructure/sync_lifecycle_handler.py @@ -139,6 +139,12 @@ async def handle( sync_run.logs.append( f"[{now.isoformat()}] Ingestion context prepared for later extraction." ) + await self._update_data_source_ingestion_prepared( + data_source_id=sync_run.data_source_id, + prepared_commit=payload.get("prepared_commit_sha"), + prepared_file_count=payload.get("prepared_file_count"), + no_changes_detected=payload.get("no_changes_detected") is True, + ) elif event_type == "MutationsApplied": sync_run.status = "completed" @@ -229,3 +235,30 @@ async def _update_data_source_last_sync_at( ds.record_sync_completed() ds.advance_extraction_baseline_to_tracked_head() await self._ds_repo.save(ds) + + async def _update_data_source_ingestion_prepared( + self, + *, + data_source_id: str, + prepared_commit: str | None, + prepared_file_count: int | None, + no_changes_detected: bool, + ) -> None: + """Persist ingest-only prepare metadata on the data source.""" + ds = await self._ds_repo.get_by_id(DataSourceId(value=data_source_id)) + if ds is None: + return + + commit = prepared_commit + if not commit and no_changes_detected: + commit = ds.tracked_branch_head_commit + + file_count = prepared_file_count + if no_changes_detected: + file_count = None + + ds.record_ingestion_prepared( + prepared_commit=commit, + prepared_file_count=file_count, + ) + await self._ds_repo.save(ds) diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index 1d3a380b8..f05b9b42d 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -199,6 +199,12 @@ class DataSourceResponse(BaseModel): tracked_branch_head_commit: str | None = Field( None, description="Latest known commit at the tracked source branch head" ) + last_prepared_commit: str | None = Field( + None, description="Commit SHA captured during the last ingest-only prepare" + ) + last_prepared_file_count: int | None = Field( + None, description="Number of files in the JobPackage from the last prepare" + ) connection_config: dict[str, str] = Field( default_factory=dict, description="Adapter connection configuration (non-secret)", @@ -231,6 +237,8 @@ def from_domain(cls, ds: DataSource) -> DataSourceResponse: clone_head_commit=ds.clone_head_commit, last_extraction_baseline_commit=ds.last_extraction_baseline_commit, tracked_branch_head_commit=ds.tracked_branch_head_commit, + last_prepared_commit=ds.last_prepared_commit, + last_prepared_file_count=ds.last_prepared_file_count, connection_config=dict(ds.connection_config), created_at=ds.created_at, updated_at=ds.updated_at, @@ -476,6 +484,12 @@ class DataSourceWithSyncResponse(BaseModel): tracked_branch_head_commit: str | None = Field( None, description="Latest known commit at the tracked source branch head" ) + last_prepared_commit: str | None = Field( + None, description="Commit SHA captured during the last ingest-only prepare" + ) + last_prepared_file_count: int | None = Field( + None, description="Number of files in the JobPackage from the last prepare" + ) connection_config: dict[str, str] = Field( default_factory=dict, description="Adapter connection configuration (non-secret)", @@ -515,6 +529,8 @@ def from_domain_pair( clone_head_commit=ds.clone_head_commit, last_extraction_baseline_commit=ds.last_extraction_baseline_commit, tracked_branch_head_commit=ds.tracked_branch_head_commit, + last_prepared_commit=ds.last_prepared_commit, + last_prepared_file_count=ds.last_prepared_file_count, connection_config=dict(ds.connection_config), created_at=ds.created_at, updated_at=ds.updated_at, diff --git a/src/api/tests/unit/ingestion/application/test_ingestion_service.py b/src/api/tests/unit/ingestion/application/test_ingestion_service.py index 9d5be9cd8..3a06cf64f 100644 --- a/src/api/tests/unit/ingestion/application/test_ingestion_service.py +++ b/src/api/tests/unit/ingestion/application/test_ingestion_service.py @@ -12,6 +12,7 @@ import pytest from ingestion.application.services.ingestion_service import IngestionService +from ingestion.application.value_objects import IngestionRunResult from ingestion.ports.adapters import ExtractionResult, IDatasourceAdapter from shared_kernel.job_package.value_objects import ( AdapterCheckpoint, @@ -39,7 +40,7 @@ def _make_extraction_result( content_type="text/x-python", metadata={}, ) - checkpoint = AdapterCheckpoint(schema_version="1.0.0", data={}) + checkpoint = AdapterCheckpoint(schema_version="1.0.0", data={"commit_sha": "deadbeef"}) return ExtractionResult( changeset_entries=[entry], content_blobs={content_ref.hex_digest: content}, @@ -95,7 +96,7 @@ async def test_run_returns_job_package_id(self): adapter_registry=registry, work_dir=Path(tmpdir), ) - job_id = await service.run( + result = await service.run( sync_run_id="run-001", data_source_id="ds-001", knowledge_graph_id="kg-001", @@ -104,7 +105,10 @@ async def test_run_returns_job_package_id(self): credentials_path=None, ) - assert isinstance(job_id, JobPackageId) + assert isinstance(result, IngestionRunResult) + assert isinstance(result.job_package_id, JobPackageId) + assert result.entry_count == 1 + assert result.prepared_commit_sha == "deadbeef" async def test_run_creates_zip_archive(self): """run() should create a ZIP archive in the work directory.""" @@ -117,7 +121,7 @@ async def test_run_creates_zip_archive(self): adapter_registry=registry, work_dir=work_dir, ) - job_id = await service.run( + result = await service.run( sync_run_id="run-001", data_source_id="ds-001", knowledge_graph_id="kg-001", @@ -126,7 +130,7 @@ async def test_run_creates_zip_archive(self): credentials_path=None, ) # The archive should exist - archive_path = work_dir / job_id.archive_name() + archive_path = work_dir / result.job_package_id.archive_name() assert archive_path.exists() async def test_run_raises_for_unknown_adapter(self): @@ -173,7 +177,7 @@ async def test_run_handles_empty_changeset(self): adapter_registry=registry, work_dir=Path(tmpdir), ) - job_id = await service.run( + result = await service.run( sync_run_id="run-001", data_source_id="ds-001", knowledge_graph_id="kg-001", @@ -181,7 +185,7 @@ async def test_run_handles_empty_changeset(self): connection_config={}, credentials_path=None, ) - assert isinstance(job_id, JobPackageId) + assert isinstance(result, IngestionRunResult) async def test_run_uses_baseline_commit_as_checkpoint(self): """run() should convert baseline_commit into an adapter checkpoint.""" diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index ec77a0b35..ce23acb01 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -14,6 +14,7 @@ import pytest from ingestion.infrastructure.event_handler import IngestionEventHandler +from ingestion.application.value_objects import IngestionRunResult from shared_kernel.job_package.value_objects import ( JobPackageId, ) @@ -69,7 +70,7 @@ async def run( tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, - ) -> JobPackageId: + ) -> IngestionRunResult: self.calls.append( { "sync_run_id": sync_run_id, @@ -82,7 +83,11 @@ async def run( ) if self._fail: raise RuntimeError(self._error) - return JobPackageId(value="01HRZZZZZZZZZZZZZZZZZZZZZ0") + return IngestionRunResult( + job_package_id=JobPackageId(value="01HRZZZZZZZZZZZZZZZZZZZZZ0"), + entry_count=42, + prepared_commit_sha="abc123def456", + ) @pytest.fixture @@ -252,6 +257,8 @@ async def test_emits_ingestion_prepared_when_ingest_only( event = outbox.appended[0] assert event["event_type"] == "IngestionPrepared" assert event["payload"]["job_package_id"] is not None + assert event["payload"]["prepared_commit_sha"] == "abc123def456" + assert event["payload"]["prepared_file_count"] == 42 async def test_no_changes_ingest_only_emits_ingestion_prepared( self, diff --git a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py index 624beadc4..ae9a0e67d 100644 --- a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py +++ b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py @@ -130,18 +130,42 @@ async def test_ingestion_prepared_sets_ingested( run = _make_sync_run(status="ingesting") mock_sync_run_repo.get_by_id.return_value = run + from management.domain.aggregates import DataSource + from management.domain.value_objects import DataSourceId, Schedule, ScheduleType + from shared_kernel.datasource_types import DataSourceAdapterType + + now = datetime.now(UTC) + ds = DataSource( + id=DataSourceId(value=run.data_source_id), + knowledge_graph_id="kg-001", + tenant_id="tenant-001", + name="Repo", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"owner": "org", "repo": "repo"}, + credentials_path=None, + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + ) + mock_ds_repo.get_by_id.return_value = ds + await handler.handle( "IngestionPrepared", _payload( sync_run_id=run.id, job_package_id="pkg-001", + prepared_commit_sha="abc123", + prepared_file_count=99, ), ) saved_run: DataSourceSyncRun = mock_sync_run_repo.save.call_args[0][0] assert saved_run.status == "ingested" assert saved_run.completed_at is not None - mock_ds_repo.get_by_id.assert_not_called() + assert ds.last_prepared_commit == "abc123" + assert ds.last_prepared_file_count == 99 + mock_ds_repo.save.assert_awaited_once() @pytest.mark.asyncio diff --git a/src/api/tests/unit/management/test_data_source.py b/src/api/tests/unit/management/test_data_source.py index 4912c364c..4beb96b8a 100644 --- a/src/api/tests/unit/management/test_data_source.py +++ b/src/api/tests/unit/management/test_data_source.py @@ -432,6 +432,39 @@ def test_record_sync_completed_raises_after_deletion(self): ds.record_sync_completed() +class TestDataSourceRecordIngestionPrepared: + """Tests for DataSource.record_ingestion_prepared().""" + + def _create_ds(self, **kwargs): + defaults = { + "knowledge_graph_id": "kg-123", + "tenant_id": "tenant-456", + "name": "Source", + "adapter_type": DataSourceAdapterType.GITHUB, + "connection_config": {}, + } + defaults.update(kwargs) + ds = DataSource.create(**defaults) + ds.collect_events() + return ds + + def test_record_ingestion_prepared_sets_commit_and_file_count(self): + ds = self._create_ds() + ds.record_ingestion_prepared( + prepared_commit="abc123", + prepared_file_count=55, + ) + assert ds.last_prepared_commit == "abc123" + assert ds.last_prepared_file_count == 55 + + def test_record_ingestion_prepared_preserves_file_count_when_none(self): + ds = self._create_ds() + ds.last_prepared_file_count = 10 + ds.record_ingestion_prepared(prepared_commit="abc123", prepared_file_count=None) + assert ds.last_prepared_commit == "abc123" + assert ds.last_prepared_file_count == 10 + + class TestDataSourceMarkForDeletion: """Tests for DataSource.mark_for_deletion() method.""" diff --git a/src/api/tests/unit/test_sessioned_ingestion_handler.py b/src/api/tests/unit/test_sessioned_ingestion_handler.py index b0fc4e7ec..661a14df6 100644 --- a/src/api/tests/unit/test_sessioned_ingestion_handler.py +++ b/src/api/tests/unit/test_sessioned_ingestion_handler.py @@ -179,3 +179,69 @@ async def test_sessioned_ingestion_handler_sets_no_changes_flag_when_heads_match == {"token": "tok"} ) + +@pytest.mark.asyncio +async def test_sessioned_ingestion_handler_uses_last_prepared_for_ingest_only(): + """ingest_only runs should compare against last prepared commit, not extraction baseline.""" + from main import _SessionedIngestionEventHandler + + session = AsyncMock() + session_factory = _make_session_factory(session) + handler = _SessionedIngestionEventHandler(session_factory=session_factory) + handler._resolve_github_tracked_head_commit = AsyncMock(return_value="prepared123") # type: ignore[attr-defined] + + outbox_repo = MagicMock() + ds_repo = MagicMock() + secret_store = MagicMock() + ingestion_handler = MagicMock() + ingestion_handler.handle = AsyncMock() + ingestion_service = MagicMock() + + data_source = _make_data_source() + data_source.last_prepared_commit = "prepared123" + ds_repo.get_by_id = AsyncMock(return_value=data_source) + ds_repo.save = AsyncMock() + secret_store.retrieve = AsyncMock(return_value={"token": "tok"}) + + payload = { + "sync_run_id": "run-003", + "data_source_id": data_source.id.value, + "knowledge_graph_id": data_source.knowledge_graph_id, + "tenant_id": data_source.tenant_id, + "adapter_type": "github", + "connection_config": data_source.connection_config, + "credentials_path": data_source.credentials_path, + "pipeline_mode": "ingest_only", + } + + management_settings = MagicMock() + management_settings.encryption_key.get_secret_value.return_value = ( + "WlAwWU83a2hSODl2SVY4MHBzQWpwaDBSUHhOU3NfQ3R6aXpvNTJfNE5odz0=" + ) + + with ( + patch("infrastructure.outbox.repository.OutboxRepository", return_value=outbox_repo), + patch( + "management.infrastructure.repositories.data_source_repository.DataSourceRepository", + return_value=ds_repo, + ), + patch( + "management.infrastructure.repositories.fernet_secret_store.FernetSecretStore", + return_value=secret_store, + ), + patch( + "ingestion.application.services.ingestion_service.IngestionService", + return_value=ingestion_service, + ), + patch( + "ingestion.infrastructure.event_handler.IngestionEventHandler", + return_value=ingestion_handler, + ), + patch("main.get_management_settings", return_value=management_settings), + ): + await handler.handle("SyncStarted", payload) + + call_payload = ingestion_handler.handle.call_args.args[1] + assert call_payload["baseline_commit"] == "prepared123" + assert call_payload["no_changes_detected"] is True + diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue index 4f610dd1f..91086fd26 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -24,6 +24,7 @@ import { import { isMaintenanceReady } from '@/utils/kgManageWorkspace' import { hasAnyActiveSync, + isActiveSyncStatus, isSyncTerminal, latestSyncRun, type SyncRunStatus, @@ -31,6 +32,10 @@ import { import { commitStatusClass, commitStatusLabel, + formatPreparedFileCount, + isIngestionPreparedAtHead, + needsIngestionPrepare, + prepareCommitStatusLabel, prepStatusBadgeVariant, resolvePrepStatusLabel, resolveRepoUrl, @@ -99,6 +104,8 @@ interface DataSourceItem { connection_config?: Record<string, string> last_extraction_baseline_commit?: string | null tracked_branch_head_commit?: string | null + last_prepared_commit?: string | null + last_prepared_file_count?: number | null sync_runs?: SyncRun[] diff_summary?: DataSourceDiffSummary | null } @@ -114,9 +121,8 @@ const kgName = ref('') const dataSources = ref<DataSourceItem[]>([]) const loading = ref(false) const expandedDiffLists = ref<Record<string, boolean>>({}) -const refreshingCommitRefs = ref<Record<string, boolean>>({}) -const refreshingAllCommits = ref(false) -const adoptingBaselines = ref<Record<string, boolean>>({}) +const checkingAllCommits = ref(false) +const preparingAll = ref(false) const newUrls = ref<string[]>(['']) const addToken = ref('') @@ -140,10 +146,20 @@ const validNewUrls = computed(() => ) const preparedCount = computed(() => - dataSources.value.filter((ds) => { - const status = latestSyncRun(ds.sync_runs)?.status - return status === 'ingested' || status === 'completed' - }).length, + dataSources.value.filter((ds) => isIngestionPreparedAtHead(ds)).length, +) + +const sourcesNeedingPrepare = computed(() => + visibleDataSources.value.filter( + (ds) => needsIngestionPrepare(ds) && !isActiveSyncStatus(latestStatus(ds)), + ), +) + +const canBulkPrepare = computed( + () => + sourcesNeedingPrepare.value.length > 0 + && !preparingAll.value + && !hasAnyActiveSync(dataSources.value), ) const allSourcesPrepared = computed( @@ -251,7 +267,7 @@ async function addRepositories() { newUrls.value = [''] addToken.value = '' toast.success(`Added ${added} source${added === 1 ? '' : 's'}`, { - description: 'Refresh commits or prepare ingestion context when ready.', + description: 'Check for new commits or prepare ingestion context when ready.', }) await loadDataSources() } @@ -325,64 +341,48 @@ function toggleDiffExpanded(dsId: string) { expandedDiffLists.value[dsId] = !isDiffExpanded(dsId) } -async function triggerSync(dsId: string, mode: 'full' | 'ingest_only' = 'full') { - try { - await apiFetch(`/management/data-sources/${dsId}/sync`, { - method: 'POST', - body: mode === 'ingest_only' ? { mode: 'ingest_only' } : undefined, - }) - toast.success(mode === 'ingest_only' ? 'Preparation started' : 'Sync triggered') - await loadDataSources() - if (hasAnyActiveSync(dataSources.value)) startPolling() - } catch { - toast.error('Failed to trigger sync') - } -} - -async function refreshCommitRefs(dsId: string) { - refreshingCommitRefs.value[dsId] = true - try { - await apiFetch(`/management/data-sources/${dsId}/commit-refs/refresh`, { method: 'POST' }) - toast.success('Commit references refreshed') - await loadDataSources() - } catch { - toast.error('Failed to refresh commit references') - } finally { - refreshingCommitRefs.value[dsId] = false - } -} - -async function refreshAllCommitRefs() { +async function checkAllCommitRefs() { if (visibleDataSources.value.length === 0) return - refreshingAllCommits.value = true + checkingAllCommits.value = true try { await Promise.allSettled( visibleDataSources.value.map((ds) => apiFetch(`/management/data-sources/${ds.id}/commit-refs/refresh`, { method: 'POST' }), ), ) - toast.success('Commit references refreshed') + toast.success('Branch heads updated') await loadDataSources() } catch { - toast.error('Failed to refresh commit references') + toast.error('Failed to check for new commits') } finally { - refreshingAllCommits.value = false + checkingAllCommits.value = false } } -async function adoptTrackedHeadBaseline(dsId: string) { - adoptingBaselines.value[dsId] = true +async function prepareAllDataSources() { + const queue = sourcesNeedingPrepare.value + if (queue.length === 0) { + toast.error('No data sources need preparation') + return + } + + preparingAll.value = true try { - await apiFetch(`/management/data-sources/${dsId}/commit-refs/adopt-tracked-head`, { - method: 'POST', - }) - toast.success('Baseline updated to tracked head') + await Promise.allSettled( + queue.map((ds) => + apiFetch(`/management/data-sources/${ds.id}/sync`, { + method: 'POST', + body: { mode: 'ingest_only' }, + }), + ), + ) + toast.success(`Preparing ${queue.length} data source${queue.length === 1 ? '' : 's'}`) await loadDataSources() - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to update baseline' - toast.error('Failed to update baseline', { description: msg }) + if (hasAnyActiveSync(dataSources.value)) startPolling() + } catch { + toast.error('Failed to start preparation') } finally { - adoptingBaselines.value[dsId] = false + preparingAll.value = false } } @@ -558,6 +558,13 @@ watch(tenantVersion, async () => { <input :value="url" type="text" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" + data-lpignore="true" + data-1p-ignore + :name="`kg-ds-repo-url-${index}`" class="flex h-9 flex-1 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" placeholder="https://github.com/org/repo" @input="updateUrl(index, ($event.target as HTMLInputElement).value)" @@ -612,18 +619,26 @@ watch(tenantVersion, async () => { <Button variant="outline" size="sm" - :disabled="refreshingAllCommits || visibleDataSources.length === 0" - @click="refreshAllCommitRefs" + :disabled="checkingAllCommits || visibleDataSources.length === 0" + @click="checkAllCommitRefs" > - <Loader2 v-if="refreshingAllCommits" class="mr-2 size-4 animate-spin" /> + <Loader2 v-if="checkingAllCommits" class="mr-2 size-4 animate-spin" /> <RefreshCw v-else class="mr-2 size-4" /> - Refresh commits + Check for new commits + </Button> + <Button + size="sm" + :disabled="!canBulkPrepare" + @click="prepareAllDataSources" + > + <Loader2 v-if="preparingAll" class="mr-2 size-4 animate-spin" /> + Prepare data sources </Button> </div> </div> <CardDescription> - Each row is one connected repository. Compare extraction baseline to tracked branch head - to see when maintenance syncs are needed. + Each row is one connected repository. Prepare ingestion context when tracked branch + head moves ahead of the last prepared commit. </CardDescription> </CardHeader> <CardContent class="space-y-3"> @@ -636,12 +651,13 @@ watch(tenantVersion, async () => { </div> <div v-else class="overflow-x-auto rounded-md border"> - <table class="w-full min-w-[880px] text-sm"> + <table class="w-full min-w-[960px] text-sm"> <thead> <tr class="border-b bg-muted/50 text-left"> <th class="px-3 py-2 font-medium">Source</th> <th class="px-3 py-2 font-medium">Branch</th> <th class="px-3 py-2 font-medium">Status</th> + <th class="px-3 py-2 font-medium">Files on branch</th> <th class="px-3 py-2 font-medium">Last extraction baseline</th> <th class="px-3 py-2 font-medium">Tracked branch head</th> <th class="px-3 py-2 font-medium">Actions</th> @@ -652,7 +668,7 @@ watch(tenantVersion, async () => { v-for="ds in visibleDataSources" :key="ds.id" class="border-b border-border/60 align-top last:border-0" - :class="isMaintenanceReady(ds) ? 'bg-amber-50/40 dark:bg-amber-950/10' : ''" + :class="needsIngestionPrepare(ds) ? 'bg-amber-50/40 dark:bg-amber-950/10' : ''" > <td class="px-3 py-2"> <p class="font-medium leading-tight">{{ ds.name }}</p> @@ -674,6 +690,9 @@ watch(tenantVersion, async () => { <SyncPhaseIndicator :status="latestStatus(ds)!" /> </div> </td> + <td class="px-3 py-2 font-mono text-xs tabular-nums"> + {{ formatPreparedFileCount(ds.last_prepared_file_count) }} + </td> <td class="px-3 py-2 font-mono text-xs"> <div :class="commitStatusClass(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit)"> <span :title="ds.last_extraction_baseline_commit || ''"> @@ -688,9 +707,19 @@ watch(tenantVersion, async () => { </div> </td> <td class="px-3 py-2 font-mono text-xs"> - <span :title="ds.tracked_branch_head_commit || ''"> - {{ shortCommitHash(ds.tracked_branch_head_commit) }} - </span> + <div + :class="commitStatusClass(ds.last_prepared_commit, ds.tracked_branch_head_commit)" + > + <span :title="ds.tracked_branch_head_commit || ''"> + {{ shortCommitHash(ds.tracked_branch_head_commit) }} + </span> + </div> + <div + class="mt-0.5 text-[10px]" + :class="commitStatusClass(ds.last_prepared_commit, ds.tracked_branch_head_commit)" + > + {{ prepareCommitStatusLabel(ds.last_prepared_commit, ds.tracked_branch_head_commit) }} + </div> </td> <td class="px-3 py-2"> <div class="flex flex-wrap gap-1"> @@ -698,33 +727,6 @@ watch(tenantVersion, async () => { <Settings class="mr-1 size-3" /> Edit </Button> - <Button - size="sm" - variant="ghost" - class="h-7 px-2 text-[10px]" - :disabled="refreshingCommitRefs[ds.id] === true" - @click="refreshCommitRefs(ds.id)" - > - <RefreshCw class="mr-1 size-3" :class="refreshingCommitRefs[ds.id] ? 'animate-spin' : ''" /> - Refresh - </Button> - <Button - size="sm" - variant="ghost" - class="h-7 px-2 text-[10px]" - :disabled="!isMaintenanceReady(ds) || adoptingBaselines[ds.id] === true" - @click="adoptTrackedHeadBaseline(ds.id)" - > - Adopt baseline - </Button> - <Button - size="sm" - variant="ghost" - class="h-7 px-2 text-[10px]" - @click="triggerSync(ds.id, 'ingest_only')" - > - Prepare - </Button> <Button size="sm" variant="ghost" @@ -739,15 +741,15 @@ watch(tenantVersion, async () => { <div v-if="ds.diff_summary && ds.diff_summary.total_changed_files > 0" class="mt-2 rounded border p-2 text-[11px]" - :class="isMaintenanceReady(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" + :class="needsIngestionPrepare(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" > <div class="flex items-center justify-between gap-2"> <span> <span class="font-medium">{{ ds.diff_summary.total_changed_files }}</span> changed files </span> - <Badge :variant="isMaintenanceReady(ds) ? 'default' : 'secondary'" class="text-[10px]"> - {{ isMaintenanceReady(ds) ? 'New commits available' : 'Up to date' }} + <Badge :variant="needsIngestionPrepare(ds) ? 'default' : 'secondary'" class="text-[10px]"> + {{ needsIngestionPrepare(ds) ? 'Prepare needed' : 'Up to date' }} </Badge> </div> <Button @@ -837,8 +839,8 @@ watch(tenantVersion, async () => { class="rounded-lg border bg-muted/50 p-4" > <p class="text-sm text-muted-foreground"> - <strong>Flow:</strong> add repository URLs above, refresh commits to resolve branch heads, - then prepare ingestion context before opening graph management. + <strong>Flow:</strong> add repository URLs above, check for new commits to resolve branch heads, + then prepare data sources before opening graph management. </p> </div> </template> diff --git a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts index 68837d47d..3f229ae86 100644 --- a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts +++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts @@ -3,7 +3,10 @@ import { readFileSync } from 'fs' import { resolve } from 'path' import { commitStatusLabel, + isIngestionPreparedAtHead, + needsIngestionPrepare, prepStatusBadgeVariant, + prepareCommitStatusLabel, resolvePrepStatusLabel, resolveRepoUrl, shortCommitHash, @@ -36,9 +39,22 @@ describe('KG data sources phase1 layout', () => { expect(phase1Vue).toContain('step=graph-management') }) - it('does not render legacy per-card commit status layout', () => { - expect(phase1Vue).not.toContain('Commit Status') - expect(phase1Vue).not.toContain('Local clone commit') + it('renders bulk commit check and prepare actions', () => { + expect(phase1Vue).toContain('Check for new commits') + expect(phase1Vue).toContain('Prepare data sources') + expect(phase1Vue).toContain('prepareAllDataSources') + expect(phase1Vue).not.toContain('Refresh commits') + expect(phase1Vue).not.toContain('Adopt baseline') + }) + + it('disables autofill on repository URL inputs', () => { + expect(phase1Vue).toContain('autocomplete="off"') + expect(phase1Vue).toContain('data-lpignore="true"') + }) + + it('shows files on branch column', () => { + expect(phase1Vue).toContain('Files on branch') + expect(phase1Vue).toContain('formatPreparedFileCount') }) }) @@ -68,5 +84,8 @@ describe('kgDataSourcesCommits helpers', () => { expect(resolvePrepStatusLabel('ingested')).toBe('Prepared') expect(prepStatusBadgeVariant('ingested')).toBe('success') expect(commitStatusLabel('abc', 'abc')).toBe('matches branch head') + expect(prepareCommitStatusLabel('abc', 'abc')).toBe('prepared at branch head') + expect(needsIngestionPrepare({ tracked_branch_head_commit: 'abc', last_prepared_commit: null })).toBe(true) + expect(isIngestionPreparedAtHead({ tracked_branch_head_commit: 'abc', last_prepared_commit: 'abc' })).toBe(true) }) }) diff --git a/src/dev-ui/app/utils/kgDataSourcesCommits.ts b/src/dev-ui/app/utils/kgDataSourcesCommits.ts index 26ab8ef0c..939b55f2e 100644 --- a/src/dev-ui/app/utils/kgDataSourcesCommits.ts +++ b/src/dev-ui/app/utils/kgDataSourcesCommits.ts @@ -23,6 +23,38 @@ export function commitStatusLabel( return current === remote ? 'matches branch head' : 'new commits on branch' } +export function prepareCommitStatusLabel( + prepared: string | null | undefined, + tracked: string | null | undefined, +): string { + if (!tracked) return 'branch head unknown' + if (!prepared) return 'not prepared yet' + return prepared === tracked ? 'prepared at branch head' : 'new commits to prepare' +} + +export function needsIngestionPrepare(ds: { + last_prepared_commit?: string | null + tracked_branch_head_commit?: string | null +}): boolean { + const tracked = ds.tracked_branch_head_commit + if (!tracked) return false + return ds.last_prepared_commit !== tracked +} + +export function isIngestionPreparedAtHead(ds: { + last_prepared_commit?: string | null + tracked_branch_head_commit?: string | null +}): boolean { + const tracked = ds.tracked_branch_head_commit + const prepared = ds.last_prepared_commit + return !!tracked && !!prepared && prepared === tracked +} + +export function formatPreparedFileCount(count: number | null | undefined): string { + if (count === null || count === undefined) return '—' + return count.toLocaleString() +} + export function resolveRepoUrl(connectionConfig: Record<string, string> | undefined): string { if (!connectionConfig) return '—' if (connectionConfig.repo_url) return connectionConfig.repo_url From 3012df564460f60307fc446792a31563ce469d2c Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Thu, 28 May 2026 21:42:57 -0400 Subject: [PATCH 59/80] feat(ui): align KG manage workspace with k-extract project hub Rework the manage overview into a phased workspace hub and add unpulled-commit tracking on data sources so ingestion status matches a git-pull mental model. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/main.py | 6 +- .../domain/aggregates/data_source.py | 1 + .../management/domain/commit_pull_state.py | 37 ++ .../presentation/data_sources/models.py | 27 + .../presentation/data_sources/routes.py | 9 +- .../domain/test_commit_pull_state.py | 63 +++ .../test_sync_lifecycle_handler.py | 1 + .../tests/unit/management/test_data_source.py | 1 + .../[kgId]/data-sources/index.vue | 102 +++- .../pages/knowledge-graphs/[kgId]/manage.vue | 502 +++++++++++++++--- .../app/tests/kg-data-sources-phase1.test.ts | 25 +- .../app/tests/kg-manage-workspace-hub.test.ts | 89 ++++ .../knowledge-graph-manage-workspace.test.ts | 50 +- src/dev-ui/app/utils/kgDataSourcesCommits.ts | 72 ++- src/dev-ui/app/utils/kgManageWorkspaceHub.ts | 267 ++++++++++ 15 files changed, 1106 insertions(+), 146 deletions(-) create mode 100644 src/api/management/domain/commit_pull_state.py create mode 100644 src/api/tests/unit/management/domain/test_commit_pull_state.py create mode 100644 src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts create mode 100644 src/dev-ui/app/utils/kgManageWorkspaceHub.ts diff --git a/src/api/main.py b/src/api/main.py index ecdf42dcc..da073481e 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -251,7 +251,11 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: if ds is not None: pipeline_mode = str(payload.get("pipeline_mode", "full")) if pipeline_mode == "ingest_only": - baseline_commit = ds.last_prepared_commit + from management.domain.commit_pull_state import ( + resolve_ingested_head_commit, + ) + + baseline_commit = resolve_ingested_head_commit(ds) else: baseline_commit = ds.last_extraction_baseline_commit if baseline_commit: diff --git a/src/api/management/domain/aggregates/data_source.py b/src/api/management/domain/aggregates/data_source.py index 09fe5aa38..075ecf049 100644 --- a/src/api/management/domain/aggregates/data_source.py +++ b/src/api/management/domain/aggregates/data_source.py @@ -397,6 +397,7 @@ def record_ingestion_prepared( ) if prepared_commit: self.last_prepared_commit = prepared_commit + self.clone_head_commit = prepared_commit if prepared_file_count is not None: self.last_prepared_file_count = prepared_file_count diff --git a/src/api/management/domain/commit_pull_state.py b/src/api/management/domain/commit_pull_state.py new file mode 100644 index 000000000..91568b468 --- /dev/null +++ b/src/api/management/domain/commit_pull_state.py @@ -0,0 +1,37 @@ +"""Git pull-style commit state for Git-backed data sources. + +``tracked_branch_head_commit`` is the remote branch tip (what ``git pull`` would +fast-forward to). ``clone_head_commit`` / ``last_prepared_commit`` record what +we have ingested locally. ``newest_unpulled_commit`` is the branch tip when it +differs from what we have — the newest commit on the branch we do not have yet. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from management.domain.aggregates import DataSource + + +def resolve_ingested_head_commit(data_source: DataSource) -> str | None: + """Commit whose content we have ingested (local HEAD after pull/prepare).""" + return data_source.clone_head_commit or data_source.last_prepared_commit + + +def resolve_newest_unpulled_commit(data_source: DataSource) -> str | None: + """Newest commit on the tracked branch that we do not have yet, if any.""" + remote_tip = data_source.tracked_branch_head_commit + if not remote_tip: + return None + ingested = resolve_ingested_head_commit(data_source) + if not ingested: + return remote_tip + if ingested == remote_tip: + return None + return remote_tip + + +def has_unpulled_commits(data_source: DataSource) -> bool: + """True when the remote branch tip is ahead of our ingested head.""" + return resolve_newest_unpulled_commit(data_source) is not None diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index f05b9b42d..ec4aca13a 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -9,6 +9,10 @@ from management.application.services.data_source_service import DataSourceWithLatestRun from management.domain.aggregates import DataSource +from management.domain.commit_pull_state import ( + resolve_ingested_head_commit, + resolve_newest_unpulled_commit, +) from management.domain.entities import DataSourceSyncRun from management.domain.value_objects import Ontology, OntologyEdgeType, OntologyNodeType @@ -205,6 +209,17 @@ class DataSourceResponse(BaseModel): last_prepared_file_count: int | None = Field( None, description="Number of files in the JobPackage from the last prepare" ) + ingested_head_commit: str | None = Field( + None, + description="Commit we have ingested locally (clone head / last prepare)", + ) + newest_unpulled_commit: str | None = Field( + None, + description=( + "Newest commit on the tracked branch we do not have yet; " + "null when up to date with branch tip" + ), + ) connection_config: dict[str, str] = Field( default_factory=dict, description="Adapter connection configuration (non-secret)", @@ -239,6 +254,8 @@ def from_domain(cls, ds: DataSource) -> DataSourceResponse: tracked_branch_head_commit=ds.tracked_branch_head_commit, last_prepared_commit=ds.last_prepared_commit, last_prepared_file_count=ds.last_prepared_file_count, + ingested_head_commit=resolve_ingested_head_commit(ds), + newest_unpulled_commit=resolve_newest_unpulled_commit(ds), connection_config=dict(ds.connection_config), created_at=ds.created_at, updated_at=ds.updated_at, @@ -490,6 +507,14 @@ class DataSourceWithSyncResponse(BaseModel): last_prepared_file_count: int | None = Field( None, description="Number of files in the JobPackage from the last prepare" ) + ingested_head_commit: str | None = Field( + None, + description="Commit we have ingested locally (clone head / last prepare)", + ) + newest_unpulled_commit: str | None = Field( + None, + description="Newest commit on branch we do not have yet; null if up to date", + ) connection_config: dict[str, str] = Field( default_factory=dict, description="Adapter connection configuration (non-secret)", @@ -531,6 +556,8 @@ def from_domain_pair( tracked_branch_head_commit=ds.tracked_branch_head_commit, last_prepared_commit=ds.last_prepared_commit, last_prepared_file_count=ds.last_prepared_file_count, + ingested_head_commit=resolve_ingested_head_commit(ds), + newest_unpulled_commit=resolve_newest_unpulled_commit(ds), connection_config=dict(ds.connection_config), created_at=ds.created_at, updated_at=ds.updated_at, diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index ea67a40af..d063859ee 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -66,7 +66,7 @@ def _build_operation_count_entry_previews( @router.post( "/data-sources/{ds_id}/commit-refs/refresh", status_code=status.HTTP_200_OK, - summary="Refresh source commit references for a data source", + summary="Check remote branch tip and unpulled commits for a data source", ) async def refresh_commit_references( ds_id: str, @@ -76,7 +76,12 @@ async def refresh_commit_references( GitCommitReferenceService, Depends(get_git_commit_reference_service) ], ) -> DataSourceResponse: - """Refresh tracked/cloned commit references for a Git-backed data source.""" + """Resolve the remote branch tip and whether we have unpulled commits. + + Updates ``tracked_branch_head_commit`` to the current GitHub branch HEAD + (the commit ``git pull`` would fast-forward to). The response includes + ``newest_unpulled_commit`` when that tip is ahead of our ingested head. + """ try: ds = await service.get( user_id=current_user.user_id.value, diff --git a/src/api/tests/unit/management/domain/test_commit_pull_state.py b/src/api/tests/unit/management/domain/test_commit_pull_state.py new file mode 100644 index 000000000..ec499f19a --- /dev/null +++ b/src/api/tests/unit/management/domain/test_commit_pull_state.py @@ -0,0 +1,63 @@ +"""Unit tests for git pull-style commit state helpers.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from management.domain.aggregates import DataSource +from management.domain.commit_pull_state import ( + has_unpulled_commits, + resolve_ingested_head_commit, + resolve_newest_unpulled_commit, +) +from management.domain.value_objects import DataSourceId, Schedule, ScheduleType +from shared_kernel.datasource_types import DataSourceAdapterType + + +def _ds(**overrides) -> DataSource: + now = datetime.now(UTC) + base = dict( + id=DataSourceId(value="01JTESTCOMMITPULLSTATE000"), + knowledge_graph_id="kg-001", + tenant_id="tenant-001", + name="repo", + adapter_type=DataSourceAdapterType.GITHUB, + connection_config={"owner": "o", "repo": "r", "branch": "main"}, + credentials_path=None, + schedule=Schedule(schedule_type=ScheduleType.MANUAL), + last_sync_at=None, + created_at=now, + updated_at=now, + clone_head_commit=None, + last_prepared_commit=None, + tracked_branch_head_commit=None, + ) + base.update(overrides) + return DataSource(**base) + + +class TestCommitPullState: + def test_ingested_head_prefers_clone_over_prepared(self): + ds = _ds(clone_head_commit="clone-sha", last_prepared_commit="prep-sha") + assert resolve_ingested_head_commit(ds) == "clone-sha" + + def test_newest_unpulled_is_remote_tip_when_never_ingested(self): + ds = _ds(tracked_branch_head_commit="remote-tip") + assert resolve_newest_unpulled_commit(ds) == "remote-tip" + assert has_unpulled_commits(ds) is True + + def test_newest_unpulled_none_when_up_to_date(self): + ds = _ds( + clone_head_commit="same-sha", + tracked_branch_head_commit="same-sha", + ) + assert resolve_newest_unpulled_commit(ds) is None + assert has_unpulled_commits(ds) is False + + def test_newest_unpulled_is_branch_tip_when_behind(self): + ds = _ds( + clone_head_commit="old-sha", + tracked_branch_head_commit="new-tip", + ) + assert resolve_newest_unpulled_commit(ds) == "new-tip" + assert has_unpulled_commits(ds) is True diff --git a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py index ae9a0e67d..dd5ab7f46 100644 --- a/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py +++ b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py @@ -164,6 +164,7 @@ async def test_ingestion_prepared_sets_ingested( assert saved_run.status == "ingested" assert saved_run.completed_at is not None assert ds.last_prepared_commit == "abc123" + assert ds.clone_head_commit == "abc123" assert ds.last_prepared_file_count == 99 mock_ds_repo.save.assert_awaited_once() diff --git a/src/api/tests/unit/management/test_data_source.py b/src/api/tests/unit/management/test_data_source.py index 4beb96b8a..49c20ae0d 100644 --- a/src/api/tests/unit/management/test_data_source.py +++ b/src/api/tests/unit/management/test_data_source.py @@ -455,6 +455,7 @@ def test_record_ingestion_prepared_sets_commit_and_file_count(self): prepared_file_count=55, ) assert ds.last_prepared_commit == "abc123" + assert ds.clone_head_commit == "abc123" assert ds.last_prepared_file_count == 55 def test_record_ingestion_prepared_preserves_file_count_when_none(self): diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue index 91086fd26..f28fe0d80 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -30,12 +30,16 @@ import { type SyncRunStatus, } from '@/utils/kgDataSourcesSync' import { + formatPreparedFileCount, commitStatusClass, commitStatusLabel, - formatPreparedFileCount, + hasUnpulledCommits, isIngestionPreparedAtHead, needsIngestionPrepare, - prepareCommitStatusLabel, + resolveBranchTipCommit, + resolveIngestedHeadCommit, + resolveNewestUnpulledCommit, + unpulledCommitStatusLabel, prepStatusBadgeVariant, resolvePrepStatusLabel, resolveRepoUrl, @@ -106,6 +110,9 @@ interface DataSourceItem { tracked_branch_head_commit?: string | null last_prepared_commit?: string | null last_prepared_file_count?: number | null + ingested_head_commit?: string | null + newest_unpulled_commit?: string | null + clone_head_commit?: string | null sync_runs?: SyncRun[] diff_summary?: DataSourceDiffSummary | null } @@ -350,8 +357,18 @@ async function checkAllCommitRefs() { apiFetch(`/management/data-sources/${ds.id}/commit-refs/refresh`, { method: 'POST' }), ), ) - toast.success('Branch heads updated') await loadDataSources() + const unpulled = visibleDataSources.value.filter((ds) => hasUnpulledCommits(ds)) + if (unpulled.length === 0) { + toast.success('Up to date with remote branches') + } else { + toast.success( + `${unpulled.length} source${unpulled.length === 1 ? '' : 's'} have unpulled commits`, + { + description: 'Newest unpulled commit is shown in the table.', + }, + ) + } } catch { toast.error('Failed to check for new commits') } finally { @@ -637,8 +654,9 @@ watch(tenantVersion, async () => { </div> </div> <CardDescription> - Each row is one connected repository. Prepare ingestion context when tracked branch - head moves ahead of the last prepared commit. + Check for new commits resolves the remote branch tip (like after + <span class="font-mono text-xs">git fetch</span>) and shows the newest commit you + have not ingested yet. Prepare pulls that content into a JobPackage. </CardDescription> </CardHeader> <CardContent class="space-y-3"> @@ -651,7 +669,7 @@ watch(tenantVersion, async () => { </div> <div v-else class="overflow-x-auto rounded-md border"> - <table class="w-full min-w-[960px] text-sm"> + <table class="w-full min-w-[1120px] text-sm"> <thead> <tr class="border-b bg-muted/50 text-left"> <th class="px-3 py-2 font-medium">Source</th> @@ -659,7 +677,9 @@ watch(tenantVersion, async () => { <th class="px-3 py-2 font-medium">Status</th> <th class="px-3 py-2 font-medium">Files on branch</th> <th class="px-3 py-2 font-medium">Last extraction baseline</th> - <th class="px-3 py-2 font-medium">Tracked branch head</th> + <th class="px-3 py-2 font-medium">Ingested at</th> + <th class="px-3 py-2 font-medium">Newest unpulled</th> + <th class="px-3 py-2 font-medium">Branch tip</th> <th class="px-3 py-2 font-medium">Actions</th> </tr> </thead> @@ -668,7 +688,7 @@ watch(tenantVersion, async () => { v-for="ds in visibleDataSources" :key="ds.id" class="border-b border-border/60 align-top last:border-0" - :class="needsIngestionPrepare(ds) ? 'bg-amber-50/40 dark:bg-amber-950/10' : ''" + :class="hasUnpulledCommits(ds) ? 'bg-amber-50/40 dark:bg-amber-950/10' : ''" > <td class="px-3 py-2"> <p class="font-medium leading-tight">{{ ds.name }}</p> @@ -694,33 +714,77 @@ watch(tenantVersion, async () => { {{ formatPreparedFileCount(ds.last_prepared_file_count) }} </td> <td class="px-3 py-2 font-mono text-xs"> - <div :class="commitStatusClass(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit)"> + <div + :class=" + commitStatusClass( + ds.last_extraction_baseline_commit, + ds.tracked_branch_head_commit, + ) + " + > <span :title="ds.last_extraction_baseline_commit || ''"> {{ shortCommitHash(ds.last_extraction_baseline_commit) }} </span> </div> <div class="mt-0.5 text-[10px]" - :class="commitStatusClass(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit)" + :class=" + commitStatusClass( + ds.last_extraction_baseline_commit, + ds.tracked_branch_head_commit, + ) + " > - {{ commitStatusLabel(ds.last_extraction_baseline_commit, ds.tracked_branch_head_commit) }} + {{ + commitStatusLabel( + ds.last_extraction_baseline_commit, + ds.tracked_branch_head_commit, + ) + }} + </div> + </td> + <td class="px-3 py-2 font-mono text-xs"> + <span :title="resolveIngestedHeadCommit(ds) || ''"> + {{ shortCommitHash(resolveIngestedHeadCommit(ds)) }} + </span> + <div class="mt-0.5 text-[10px] text-muted-foreground"> + {{ resolveIngestedHeadCommit(ds) ? 'have locally' : 'nothing ingested yet' }} </div> </td> <td class="px-3 py-2 font-mono text-xs"> <div - :class="commitStatusClass(ds.last_prepared_commit, ds.tracked_branch_head_commit)" + :class=" + hasUnpulledCommits(ds) + ? 'text-amber-600 dark:text-amber-400' + : 'text-green-600 dark:text-green-400' + " > - <span :title="ds.tracked_branch_head_commit || ''"> - {{ shortCommitHash(ds.tracked_branch_head_commit) }} + <span :title="resolveNewestUnpulledCommit(ds) || ''"> + {{ shortCommitHash(resolveNewestUnpulledCommit(ds)) }} </span> </div> <div class="mt-0.5 text-[10px]" - :class="commitStatusClass(ds.last_prepared_commit, ds.tracked_branch_head_commit)" + :class=" + hasUnpulledCommits(ds) + ? 'text-amber-600 dark:text-amber-400' + : 'text-muted-foreground' + " > - {{ prepareCommitStatusLabel(ds.last_prepared_commit, ds.tracked_branch_head_commit) }} + {{ + unpulledCommitStatusLabel( + resolveNewestUnpulledCommit(ds), + resolveBranchTipCommit(ds), + ) + }} </div> </td> + <td class="px-3 py-2 font-mono text-xs text-muted-foreground"> + <span :title="resolveBranchTipCommit(ds) || ''"> + {{ shortCommitHash(resolveBranchTipCommit(ds)) }} + </span> + <div class="mt-0.5 text-[10px] text-muted-foreground">remote tip</div> + </td> <td class="px-3 py-2"> <div class="flex flex-wrap gap-1"> <Button size="sm" variant="ghost" class="h-7 px-2 text-[10px]" @click="openEditConfig(ds)"> @@ -741,15 +805,15 @@ watch(tenantVersion, async () => { <div v-if="ds.diff_summary && ds.diff_summary.total_changed_files > 0" class="mt-2 rounded border p-2 text-[11px]" - :class="needsIngestionPrepare(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" + :class="hasUnpulledCommits(ds) ? 'border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20' : 'bg-muted/10'" > <div class="flex items-center justify-between gap-2"> <span> <span class="font-medium">{{ ds.diff_summary.total_changed_files }}</span> changed files </span> - <Badge :variant="needsIngestionPrepare(ds) ? 'default' : 'secondary'" class="text-[10px]"> - {{ needsIngestionPrepare(ds) ? 'Prepare needed' : 'Up to date' }} + <Badge :variant="hasUnpulledCommits(ds) ? 'default' : 'secondary'" class="text-[10px]"> + {{ hasUnpulledCommits(ds) ? 'Unpulled commits' : 'Up to date' }} </Badge> </div> <Button diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 8f175b9c2..3b3fd268d 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -1,11 +1,41 @@ <script setup lang="ts"> import { computed, onMounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' -import { ArrowLeft, CheckCircle2, Coins, DollarSign, Loader2, PlayCircle, ShieldAlert } from 'lucide-vue-next' +import { + ArrowLeft, + ArrowRight, + Box, + CheckCircle2, + ChevronLeft, + Coins, + Database, + DollarSign, + FileText, + GitBranch, + Link2, + Loader2, + Lock, + MessageSquare, + PlayCircle, + ScrollText, + ShieldAlert, + Trash2, + Wrench, +} from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import SharedConversationPanel from '@/components/extraction/SharedConversationPanel.vue' import { GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS, @@ -23,13 +53,21 @@ import { } from '@/utils/kgGraphManagement' import { buildManageStepUrl, - buildSuggestedNextStep, - buildWorkspaceStepCards, parseManageStepQuery, - resolveStepDestination, stepStatusTintClass, - type WorkspaceStepId, } from '@/utils/kgManageWorkspace' +import { + buildWorkspaceHubNextStep, + buildWorkspaceHubTiles, + resolveWorkspaceHubPhaseBadge, + workspaceHubDescription, + workspaceHubStepBadgeClass, + workspaceHubTileClasses, + type WorkspaceHubOverview, + type WorkspaceHubSourceRow, +} from '@/utils/kgManageWorkspaceHub' +import { isIngestionPreparedAtHead, resolvePrepStatusLabel, resolveRepoUrl } from '@/utils/kgDataSourcesCommits' +import { latestSyncRun } from '@/utils/kgDataSourcesSync' import { appendLocalChatMessage, buildTransitionRestrictionReason, @@ -82,8 +120,13 @@ interface KnowledgeGraphIdentity { interface DataSourceRef { id: string name: string + connection_config?: Record<string, string> last_extraction_baseline_commit?: string | null tracked_branch_head_commit?: string | null + clone_head_commit?: string | null + last_prepared_commit?: string | null + ingested_head_commit?: string | null + newest_unpulled_commit?: string | null } interface MutationLogRunView extends MutationLogRunRecord { @@ -133,7 +176,13 @@ const graphApi = useGraphApi() const kgId = computed(() => String(route.params.kgId ?? '')) const kgIdentity = ref<KnowledgeGraphIdentity | null>(null) const dataSourceCount = ref(0) +const preparedSourceCount = ref(0) const maintenanceReadyCount = ref(0) +const overviewSourceRows = ref<WorkspaceHubSourceRow[]>([]) +const entityTypeLabels = ref<string[]>([]) +const relationshipTypeLabels = ref<string[]>([]) +const deleteKgDialogOpen = ref(false) +const deletingKg = ref(false) const loading = ref(false) const workspaceLoadError = ref<string | null>(null) const workspaceForbidden = ref(false) @@ -187,8 +236,24 @@ const workspaceOverviewInput = computed(() => ({ workspaceStatus: statusProjection.value, })) -const workspaceStepCards = computed(() => buildWorkspaceStepCards(workspaceOverviewInput.value)) -const suggestedNextStep = computed(() => buildSuggestedNextStep(workspaceOverviewInput.value)) +const workspaceHubOverview = computed((): WorkspaceHubOverview => ({ + ...workspaceOverviewInput.value, + preparedSourceCount: preparedSourceCount.value, + entityTypeLabels: entityTypeLabels.value, + relationshipTypeLabels: relationshipTypeLabels.value, +})) + +const workspaceHubTiles = computed(() => buildWorkspaceHubTiles(workspaceHubOverview.value)) +const workspaceHubNextStep = computed(() => buildWorkspaceHubNextStep(workspaceHubOverview.value)) +const workspaceHubPhaseBadge = computed(() => resolveWorkspaceHubPhaseBadge(workspaceHubOverview.value)) +const workspaceHubDescriptionText = computed(() => workspaceHubDescription(workspaceHubOverview.value)) + +const workspaceHubTileIcons = { + 'data-sources': GitBranch, + 'graph-management': MessageSquare, + 'mutation-logs': ScrollText, + maintain: Wrench, +} as const const graphHeaderTitle = computed(() => kgIdentity.value?.name ?? 'Knowledge Graph Manage Workspace', @@ -382,9 +447,78 @@ async function loadOverviewMetrics() { if (!ds.last_extraction_baseline_commit || !ds.tracked_branch_head_commit) return false return ds.last_extraction_baseline_commit !== ds.tracked_branch_head_commit }).length + + let prepared = 0 + const rows: WorkspaceHubSourceRow[] = [] + for (const ds of dataSources) { + let status = 'not prepared' + let statusVariant: WorkspaceHubSourceRow['statusVariant'] = 'secondary' + try { + const runs = await apiFetch<Array<{ status: string }>>( + `/management/data-sources/${ds.id}/sync-runs`, + ) + const latest = latestSyncRun(runs) + if (latest) { + status = resolvePrepStatusLabel(latest.status).toLowerCase() + if (latest.status === 'ingested' || latest.status === 'completed') { + statusVariant = 'success' + } + } + } catch { + // keep default status + } + if (isIngestionPreparedAtHead(ds)) { + prepared += 1 + if (status === 'not prepared') { + status = 'prepared' + statusVariant = 'success' + } + } + rows.push({ + id: ds.id, + name: ds.name, + url: resolveRepoUrl(ds.connection_config), + status, + statusVariant, + }) + } + preparedSourceCount.value = prepared + overviewSourceRows.value = rows + + try { + const ontology = await apiFetch<{ + node_types?: Array<{ label: string }> + edge_types?: Array<{ label: string }> + }>(`/management/knowledge-graphs/${kgId.value}/ontology`) + entityTypeLabels.value = (ontology.node_types ?? []).map((t) => t.label) + relationshipTypeLabels.value = (ontology.edge_types ?? []).map((t) => t.label) + } catch { + entityTypeLabels.value = [] + relationshipTypeLabels.value = [] + } } catch { dataSourceCount.value = 0 + preparedSourceCount.value = 0 maintenanceReadyCount.value = 0 + overviewSourceRows.value = [] + entityTypeLabels.value = [] + relationshipTypeLabels.value = [] + } +} + +async function handleDeleteKnowledgeGraph() { + deletingKg.value = true + try { + await apiFetch(`/management/knowledge-graphs/${kgId.value}`, { method: 'DELETE' }) + toast.success(`Knowledge graph "${kgIdentity.value?.name ?? kgId.value}" deleted`) + deleteKgDialogOpen.value = false + await navigateTo('/knowledge-graphs') + } catch (err) { + toast.error('Failed to delete knowledge graph', { + description: extractErrorMessage(err), + }) + } finally { + deletingKg.value = false } } @@ -486,12 +620,6 @@ async function applyInlineMutations() { } } -function openWorkspaceStep(stepId: WorkspaceStepId) { - navigateTo(resolveStepDestination(kgId.value, stepId, { - dataSourceCount: dataSourceCount.value, - })) -} - function returnToWorkspaceOverview() { navigateTo(buildManageStepUrl(kgId.value)) } @@ -696,10 +824,6 @@ function onRailKeydown(event: KeyboardEvent, itemId: GraphManagementRailItemId) handleActivatableKeydown(event, () => selectRailItem(itemId)) } -function onStepActionKeydown(event: KeyboardEvent, stepId: WorkspaceStepId) { - handleActivatableKeydown(event, () => openWorkspaceStep(stepId)) -} - function onModeSwitchKeydown(event: KeyboardEvent, mode: GraphManagementMode) { handleActivatableKeydown(event, () => setGraphManagementMode(mode)) } @@ -826,7 +950,11 @@ watch(tenantVersion, () => { statusProjection.value = null extractionSession.value = null dataSourceCount.value = 0 + preparedSourceCount.value = 0 maintenanceReadyCount.value = 0 + overviewSourceRows.value = [] + entityTypeLabels.value = [] + relationshipTypeLabels.value = [] workspaceLoadError.value = null workspaceForbidden.value = false workspaceForbiddenReason.value = null @@ -876,35 +1004,39 @@ watch(selectedOpsDataSourceId, () => { <template> <div class="space-y-6"> - <div class="flex items-center justify-between"> - <div class="space-y-1"> - <div class="flex items-center gap-2"> - <h1 class="text-2xl font-semibold tracking-tight">{{ graphHeaderTitle }}</h1> - <Badge v-if="!showOverview" variant="secondary">{{ stepBadgeLabel }}</Badge> - </div> - <p class="text-sm text-muted-foreground"> - <template v-if="showOverview"> - Project workspace for knowledge graph {{ kgId }}. - </template> - <template v-else-if="activeStep === 'graph-management'"> - Conversation-first graph management with shared session and mode-specific workspace panels. - </template> - <template v-else> - Knowledge-graph scoped mutation run visibility and run metrics. - </template> - </p> - </div> - <Button - variant="outline" - size="sm" - @click="showOverview ? navigateTo('/knowledge-graphs') : returnToWorkspaceOverview()" + <template v-if="showOverview"> + <NuxtLink + to="/knowledge-graphs" + class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground" > - <ArrowLeft class="mr-1.5 size-3.5" /> - {{ showOverview ? 'Back to Knowledge Graphs' : 'Back to workspace overview' }} - </Button> - </div> + <ChevronLeft class="mr-1 size-4" /> + Back to Knowledge Graphs + </NuxtLink> + </template> - <Separator /> + <template v-else> + <div class="flex items-center justify-between"> + <div class="space-y-1"> + <div class="flex items-center gap-2"> + <h1 class="text-2xl font-semibold tracking-tight">{{ graphHeaderTitle }}</h1> + <Badge variant="secondary">{{ stepBadgeLabel }}</Badge> + </div> + <p class="text-sm text-muted-foreground"> + <template v-if="activeStep === 'graph-management'"> + Conversation-first graph management with shared session and mode-specific workspace panels. + </template> + <template v-else> + Knowledge-graph scoped mutation run visibility and run metrics. + </template> + </p> + </div> + <Button variant="outline" size="sm" @click="returnToWorkspaceOverview()"> + <ArrowLeft class="mr-1.5 size-3.5" /> + Back to workspace overview + </Button> + </div> + <Separator /> + </template> <div v-if="!hasTenant" class="rounded-lg border border-dashed p-6 text-sm text-muted-foreground"> Select a tenant to manage this workspace. @@ -912,10 +1044,10 @@ watch(selectedOpsDataSourceId, () => { <div v-else-if="workspaceOverviewState.phase === 'loading'" - class="flex items-center gap-2 text-sm text-muted-foreground" + class="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground" role="status" > - <Loader2 class="size-4 animate-spin" /> + <Loader2 class="size-8 animate-spin" /> {{ workspaceOverviewState.message }} </div> @@ -942,50 +1074,240 @@ watch(selectedOpsDataSourceId, () => { <template v-else-if="statusProjection"> <section v-if="showOverview" class="space-y-6"> - <div> - <h2 class="text-lg font-semibold tracking-tight">Project workspace</h2> - <p class="text-sm text-muted-foreground"> - Choose a step to continue work on this knowledge graph without re-selecting context. - </p> + <div class="flex items-start justify-between gap-4"> + <div class="flex min-w-0 items-center gap-3"> + <Database class="size-8 shrink-0 text-primary" /> + <div class="min-w-0"> + <h2 class="text-2xl font-bold tracking-tight">{{ graphHeaderTitle }}</h2> + <p class="truncate font-mono text-sm text-muted-foreground">{{ kgId }}</p> + <p + v-if="kgIdentity?.description" + class="mt-0.5 text-sm text-muted-foreground" + > + {{ kgIdentity.description }} + </p> + </div> + </div> + <div class="flex shrink-0 items-center gap-2"> + <Button variant="destructive" size="sm" class="gap-1.5" @click="deleteKgDialogOpen = true"> + <Trash2 class="size-4" /> + Delete + </Button> + <Badge :variant="workspaceHubPhaseBadge.variant" class="text-sm"> + {{ workspaceHubPhaseBadge.label }} + </Badge> + </div> </div> - <Card class="border-primary/30 bg-primary/5"> + <Separator /> + + <Card class="border-border"> <CardHeader class="pb-3"> - <CardTitle class="text-base">Suggested next step</CardTitle> - <CardDescription>{{ suggestedNextStep.description }}</CardDescription> + <CardTitle class="text-base">Project workspace</CardTitle> + <CardDescription>{{ workspaceHubDescriptionText }}</CardDescription> + </CardHeader> + <CardContent class="space-y-4"> + <div + class="flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between" + :class=" + workspaceHubNextStep.primaryPhase + ? 'border-primary/25 bg-primary/5' + : 'border-border bg-muted/40' + " + > + <div class="min-w-0 space-y-1"> + <p + class="text-xs font-semibold uppercase tracking-wide" + :class="workspaceHubNextStep.primaryPhase ? 'text-primary' : 'text-muted-foreground'" + > + {{ workspaceHubNextStep.primaryPhase ? 'Next step' : 'Suggested next step' }} + </p> + <p class="text-sm font-medium leading-snug">{{ workspaceHubNextStep.title }}</p> + <p class="text-sm leading-snug text-muted-foreground">{{ workspaceHubNextStep.description }}</p> + </div> + <Button + as-child + :variant="workspaceHubNextStep.primaryPhase ? 'default' : 'secondary'" + class="w-full shrink-0 sm:w-auto" + > + <NuxtLink :to="workspaceHubNextStep.to" class="inline-flex items-center justify-center gap-2"> + {{ workspaceHubNextStep.label }} + <ArrowRight class="size-4" /> + </NuxtLink> + </Button> + </div> + + <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> + <template v-for="item in workspaceHubTiles" :key="item.key"> + <NuxtLink + v-if="item.enabled" + :to="item.to" + class="flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + :class="[ + workspaceHubTileClasses(item), + item.tone === 'success' + ? 'hover:bg-green-500/10 dark:hover:bg-green-950/30' + : 'hover:bg-muted/60', + ]" + > + <div class="flex items-start justify-between gap-2"> + <div class="flex min-w-0 flex-1 items-center gap-2"> + <component + :is="workspaceHubTileIcons[item.key]" + class="size-4 shrink-0" + :class=" + item.tone === 'success' + ? 'text-green-600 dark:text-green-400' + : 'text-primary' + " + /> + <span class="text-sm font-semibold leading-tight">{{ item.title }}</span> + </div> + <div :class="workspaceHubStepBadgeClass(item)"> + <CheckCircle2 v-if="item.done" class="size-4" /> + <span v-else class="text-xs font-bold leading-none">{{ item.step }}</span> + </div> + </div> + <p class="text-xs leading-snug text-muted-foreground">{{ item.subtitle }}</p> + <span + class="text-xs font-medium" + :class=" + item.tone === 'success' + ? 'text-green-700 dark:text-green-400' + : 'text-primary' + " + > + {{ item.linkLabel }} + </span> + </NuxtLink> + <div + v-else + class="flex flex-col gap-2 rounded-lg border border-dashed border-rose-200/80 bg-rose-500/[0.04] p-4 text-left text-muted-foreground dark:border-rose-900/40 dark:bg-rose-950/20" + :title="item.lockedReason || 'Locked'" + > + <div class="flex items-start justify-between gap-2"> + <div class="flex min-w-0 flex-1 items-center gap-2"> + <Lock class="size-4 shrink-0 text-rose-700/70 dark:text-rose-400/80" /> + <span class="text-sm font-semibold leading-tight text-foreground/80">{{ item.title }}</span> + </div> + <div :class="workspaceHubStepBadgeClass(item)"> + <span class="text-xs font-bold leading-none">{{ item.step }}</span> + </div> + </div> + <p class="text-xs leading-snug">{{ item.subtitle }}</p> + <p class="text-xs text-rose-800/90 dark:text-rose-300/90">{{ item.lockedReason }}</p> + </div> + </template> + </div> + </CardContent> + </Card> + + <div class="grid gap-4 md:grid-cols-4"> + <Card> + <CardContent class="flex items-center gap-3 p-4"> + <div class="rounded-md bg-muted p-2"> + <GitBranch class="size-4 text-muted-foreground" /> + </div> + <div> + <div class="text-2xl font-bold">{{ dataSourceCount }}</div> + <p class="text-xs text-muted-foreground">Data Sources</p> + </div> + </CardContent> + </Card> + <Card> + <CardContent class="flex items-center gap-3 p-4"> + <div class="rounded-md bg-muted p-2"> + <Box class="size-4 text-muted-foreground" /> + </div> + <div> + <div class="text-2xl font-bold">{{ entityTypeLabels.length }}</div> + <p class="text-xs text-muted-foreground">Entity Types</p> + </div> + </CardContent> + </Card> + <Card> + <CardContent class="flex items-center gap-3 p-4"> + <div class="rounded-md bg-muted p-2"> + <Link2 class="size-4 text-muted-foreground" /> + </div> + <div> + <div class="text-2xl font-bold">{{ relationshipTypeLabels.length }}</div> + <p class="text-xs text-muted-foreground">Relationship Types</p> + </div> + </CardContent> + </Card> + <Card> + <CardContent class="flex items-center gap-3 p-4"> + <div class="rounded-md bg-muted p-2"> + <FileText class="size-4 text-muted-foreground" /> + </div> + <div> + <div class="text-2xl font-bold">{{ mutationLogRuns.length }}</div> + <p class="text-xs text-muted-foreground">Mutation Runs</p> + </div> + </CardContent> + </Card> + </div> + + <Card> + <CardHeader> + <CardTitle class="text-base">Data Sources</CardTitle> + <CardDescription>Configured repositories for this knowledge graph</CardDescription> </CardHeader> <CardContent> - <Button @click="openWorkspaceStep(suggestedNextStep.stepId)"> - {{ suggestedNextStep.actionLabel }} {{ suggestedNextStep.title }} - </Button> + <div v-if="overviewSourceRows.length === 0" class="text-sm text-muted-foreground"> + No data sources configured yet. + </div> + <div v-else class="space-y-3"> + <div + v-for="source in overviewSourceRows" + :key="source.id" + class="flex items-center justify-between rounded-lg border p-3" + > + <div class="flex min-w-0 items-center gap-3"> + <GitBranch class="size-4 shrink-0 text-muted-foreground" /> + <div class="min-w-0"> + <p class="font-medium">{{ source.name }}</p> + <p class="truncate font-mono text-xs text-muted-foreground">{{ source.url }}</p> + </div> + </div> + <Badge :variant="source.statusVariant">{{ source.status }}</Badge> + </div> + </div> </CardContent> </Card> - <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> - <!-- Step cards: Data Sources, Graph Management, MutationLogs, Maintain --> - <Card - v-for="card in workspaceStepCards" - :key="card.id" - class="flex flex-col" - :class="stepStatusTintClass(card.status)" - > - <CardHeader class="pb-3"> - <div class="flex items-center justify-between gap-2"> - <CardTitle class="text-base">{{ card.title }}</CardTitle> - <Badge variant="outline">{{ card.status }}</Badge> + <div class="grid gap-4 md:grid-cols-2"> + <Card> + <CardHeader> + <CardTitle class="text-base">Entity Types</CardTitle> + <CardDescription>Node types in the knowledge graph ontology</CardDescription> + </CardHeader> + <CardContent> + <div v-if="entityTypeLabels.length === 0" class="text-sm text-muted-foreground"> + No entity types defined yet. + </div> + <div v-else class="flex flex-wrap gap-2"> + <Badge v-for="label in entityTypeLabels" :key="label" variant="outline"> + {{ label }} + </Badge> </div> - <CardDescription>{{ card.statusDetail }}</CardDescription> + </CardContent> + </Card> + <Card> + <CardHeader> + <CardTitle class="text-base">Relationship Types</CardTitle> + <CardDescription>Edge types connecting entities</CardDescription> </CardHeader> - <CardContent class="mt-auto"> - <Button - class="w-full" - variant="outline" - tabindex="0" - @click="openWorkspaceStep(card.id)" - @keydown="onStepActionKeydown($event, card.id)" - > - {{ card.actionLabel }} - </Button> + <CardContent> + <div v-if="relationshipTypeLabels.length === 0" class="text-sm text-muted-foreground"> + No relationship types defined yet. + </div> + <div v-else class="flex flex-wrap gap-2"> + <Badge v-for="label in relationshipTypeLabels" :key="label" variant="outline"> + {{ label }} + </Badge> + </div> </CardContent> </Card> </div> @@ -1638,5 +1960,25 @@ watch(selectedOpsDataSourceId, () => { </div> </section> </template> + + <AlertDialog v-model:open="deleteKgDialogOpen"> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete this knowledge graph?</AlertDialogTitle> + <AlertDialogDescription> + This permanently deletes + <span class="font-medium text-foreground">{{ kgIdentity?.name ?? kgId }}</span> + and its configuration. Data sources and sync history for this graph will be removed. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel :disabled="deletingKg">Cancel</AlertDialogCancel> + <AlertDialogAction :disabled="deletingKg" @click="handleDeleteKnowledgeGraph"> + <Loader2 v-if="deletingKg" class="mr-2 size-4 animate-spin" /> + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> </template> diff --git a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts index 3f229ae86..35768bbf0 100644 --- a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts +++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts @@ -2,14 +2,15 @@ import { describe, it, expect } from 'vitest' import { readFileSync } from 'fs' import { resolve } from 'path' import { - commitStatusLabel, + hasUnpulledCommits, isIngestionPreparedAtHead, needsIngestionPrepare, prepStatusBadgeVariant, - prepareCommitStatusLabel, + resolveNewestUnpulledCommit, resolvePrepStatusLabel, resolveRepoUrl, shortCommitHash, + unpulledCommitStatusLabel, } from '@/utils/kgDataSourcesCommits' const phase1Vue = readFileSync( @@ -56,6 +57,14 @@ describe('KG data sources phase1 layout', () => { expect(phase1Vue).toContain('Files on branch') expect(phase1Vue).toContain('formatPreparedFileCount') }) + + it('shows unpulled commit columns', () => { + expect(phase1Vue).toContain('Newest unpulled') + expect(phase1Vue).toContain('Last extraction baseline') + expect(phase1Vue).toContain('Ingested at') + expect(phase1Vue).toContain('Branch tip') + expect(phase1Vue).toContain('resolveNewestUnpulledCommit') + }) }) describe('KG wizard parallel ingestion prep', () => { @@ -83,9 +92,15 @@ describe('kgDataSourcesCommits helpers', () => { it('maps sync statuses to prep labels', () => { expect(resolvePrepStatusLabel('ingested')).toBe('Prepared') expect(prepStatusBadgeVariant('ingested')).toBe('success') - expect(commitStatusLabel('abc', 'abc')).toBe('matches branch head') - expect(prepareCommitStatusLabel('abc', 'abc')).toBe('prepared at branch head') + expect( + resolveNewestUnpulledCommit({ + tracked_branch_head_commit: 'remote', + clone_head_commit: 'local', + }), + ).toBe('remote') + expect(unpulledCommitStatusLabel(null, 'remote')).toBe('up to date with branch') expect(needsIngestionPrepare({ tracked_branch_head_commit: 'abc', last_prepared_commit: null })).toBe(true) - expect(isIngestionPreparedAtHead({ tracked_branch_head_commit: 'abc', last_prepared_commit: 'abc' })).toBe(true) + expect(hasUnpulledCommits({ tracked_branch_head_commit: 'abc', clone_head_commit: 'abc' })).toBe(false) + expect(isIngestionPreparedAtHead({ tracked_branch_head_commit: 'abc', clone_head_commit: 'abc' })).toBe(true) }) }) diff --git a/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts b/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts new file mode 100644 index 000000000..858bafad6 --- /dev/null +++ b/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkspaceHubNextStep, + buildWorkspaceHubTiles, + resolveWorkspaceHubPhaseBadge, + workspaceHubDescription, + workspaceHubStepBadgeClass, + workspaceHubTileClasses, +} from '../utils/kgManageWorkspaceHub' + +const baseStatus = { + workspace_mode: 'schema_bootstrap' as const, + transition_eligible: false, + readiness: { + has_minimum_entity_types: false, + has_minimum_relationship_types: false, + prepopulated_types_ready: false, + blocking_reasons: ['Missing entity types'], + }, +} + +const baseInput = { + kgId: 'kg-1', + dataSourceCount: 0, + preparedSourceCount: 0, + maintenanceReadyCount: 0, + mutationLogRunCount: 0, + entityTypeLabels: [] as string[], + relationshipTypeLabels: [] as string[], + workspaceStatus: baseStatus, +} + +describe('kgManageWorkspaceHub', () => { + it('returns four numbered hub tiles in workspace order', () => { + const tiles = buildWorkspaceHubTiles(baseInput) + expect(tiles).toHaveLength(4) + expect(tiles.map((tile) => tile.step)).toEqual([1, 2, 3, 4]) + expect(tiles.map((tile) => tile.key)).toEqual([ + 'data-sources', + 'graph-management', + 'mutation-logs', + 'maintain', + ]) + }) + + it('locks mutation logs and maintain when prerequisites are missing', () => { + const tiles = buildWorkspaceHubTiles(baseInput) + expect(tiles.find((tile) => tile.key === 'mutation-logs')?.enabled).toBe(false) + expect(tiles.find((tile) => tile.key === 'maintain')?.enabled).toBe(false) + }) + + it('marks sources phase complete when all sources are prepared', () => { + const tiles = buildWorkspaceHubTiles({ + ...baseInput, + dataSourceCount: 2, + preparedSourceCount: 2, + }) + const sourcesTile = tiles.find((tile) => tile.key === 'data-sources') + expect(sourcesTile?.done).toBe(true) + expect(sourcesTile?.tone).toBe('success') + expect(resolveWorkspaceHubPhaseBadge({ + ...baseInput, + dataSourceCount: 2, + preparedSourceCount: 2, + }).label).toBe('Design') + }) + + it('builds a primary next-step CTA while sources phase is incomplete', () => { + const next = buildWorkspaceHubNextStep(baseInput) + expect(next.primaryPhase).toBe(true) + expect(next.title).toBe('Data Sources') + expect(next.label).toContain('Open') + }) + + it('maps tile tones to k-extract style surface classes', () => { + expect(workspaceHubTileClasses({ enabled: true, highlight: false, tone: 'success' })).toContain('green') + expect(workspaceHubTileClasses({ enabled: true, highlight: true, tone: 'primary' })).toContain('primary') + expect(workspaceHubStepBadgeClass({ enabled: true, done: true, tone: 'success' })).toContain('green-500') + }) + + it('describes workspace guidance by phase', () => { + expect(workspaceHubDescription(baseInput)).toContain('Data sources') + expect(workspaceHubDescription({ + ...baseInput, + dataSourceCount: 1, + preparedSourceCount: 1, + })).toContain('Design') + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index a7e3b1e24..77b270354 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -42,6 +42,10 @@ const sharedConversationPanelVue = readFileSync( resolve(__dirname, '../components/extraction/SharedConversationPanel.vue'), 'utf-8', ) +const manageWorkspaceHubTs = readFileSync( + resolve(__dirname, '../utils/kgManageWorkspaceHub.ts'), + 'utf-8', +) const baseWorkspaceStatus = { workspace_mode: 'schema_bootstrap' as const, @@ -184,13 +188,18 @@ describe('KG-MANAGE-001 - manage entry navigation', () => { }) }) -describe('KG-MANAGE-002 - workspace step card set', () => { - it('renders Project workspace section with exactly four step cards', () => { +describe('KG-MANAGE-002 - workspace hub tile set', () => { + it('renders Project workspace section with hub tiles and stats', () => { expect(manageWorkspaceVue).toContain('Project workspace') - expect(manageWorkspaceVue).toContain('workspaceStepCards') - for (const stepId of WORKSPACE_STEP_ORDER) { - expect(manageWorkspaceVue).toContain(WORKSPACE_STEP_TITLES[stepId]) - } + expect(manageWorkspaceVue).toContain('workspaceHubTiles') + expect(manageWorkspaceVue).toContain('workspaceHubTileClasses') + expect(manageWorkspaceVue).toContain('Entity Types') + expect(manageWorkspaceVue).toContain('Relationship Types') + expect(manageWorkspaceVue).toContain('Mutation Runs') + expect(manageWorkspaceHubTs).toContain('Data sources') + expect(manageWorkspaceHubTs).toContain('Design') + expect(manageWorkspaceHubTs).toContain('Mutation logs') + expect(manageWorkspaceHubTs).toContain('Maintain') }) it('buildWorkspaceStepCards returns the canonical four-card set', () => { @@ -212,10 +221,10 @@ describe('KG-MANAGE-002 - workspace step card set', () => { }) describe('KG-MANAGE-003 - suggested next step callout', () => { - it('renders Suggested next step callout above the card grid', () => { + it('renders next-step callout in the workspace hub card', () => { expect(manageWorkspaceVue).toContain('Suggested next step') - expect(manageWorkspaceVue).toContain('suggestedNextStep') - expect(manageWorkspaceVue).toContain('openWorkspaceStep') + expect(manageWorkspaceVue).toContain('workspaceHubNextStep') + expect(manageWorkspaceVue).toContain('Next step') }) it('prioritizes data sources when no sources are connected', () => { @@ -254,15 +263,16 @@ describe('KG-MANAGE-003 - suggested next step callout', () => { }) }) -describe('KG-MANAGE-004 - step card status semantics', () => { - it('renders status label, tint, detail text, and primary action per card', () => { - expect(manageWorkspaceVue).toContain('stepStatusTintClass') - expect(manageWorkspaceVue).toContain('card.status') - expect(manageWorkspaceVue).toContain('card.statusDetail') - expect(manageWorkspaceVue).toContain('card.actionLabel') +describe('KG-MANAGE-004 - workspace hub tile semantics', () => { + it('renders hub tile classes, badges, subtitles, and link labels', () => { + expect(manageWorkspaceVue).toContain('workspaceHubTileClasses') + expect(manageWorkspaceVue).toContain('workspaceHubStepBadgeClass') + expect(manageWorkspaceVue).toContain('item.subtitle') + expect(manageWorkspaceVue).toContain('item.linkLabel') + expect(manageWorkspaceVue).toContain('item.lockedReason') }) - it('maps each status label to a tint class', () => { + it('maps each status label to a tint class in graph-management rail', () => { expect(stepStatusTintClass('ready')).toContain('emerald') expect(stepStatusTintClass('in_progress')).toContain('blue') expect(stepStatusTintClass('needs_attention')).toContain('amber') @@ -540,10 +550,10 @@ describe('KG-MANAGE-017 - chat input keyboard contract', () => { }) describe('KG-MANAGE-018 - keyboard operable step and rail actions', () => { - it('supports keyboard activation for step card primary actions', () => { - expect(manageWorkspaceVue).toContain('onStepActionKeydown') - expect(manageWorkspaceVue).toContain('handleActivatableKeydown') - expect(manageWorkspaceVue).toContain('@keydown="onStepActionKeydown($event, card.id)"') + it('uses native links for workspace hub tiles', () => { + expect(manageWorkspaceVue).toContain('workspaceHubTiles') + expect(manageWorkspaceVue).toContain('<NuxtLink') + expect(manageWorkspaceVue).toContain('focus-visible:ring-2 focus-visible:ring-ring') }) it('supports keyboard activation for graph management rail selection', () => { diff --git a/src/dev-ui/app/utils/kgDataSourcesCommits.ts b/src/dev-ui/app/utils/kgDataSourcesCommits.ts index 939b55f2e..a5ea69801 100644 --- a/src/dev-ui/app/utils/kgDataSourcesCommits.ts +++ b/src/dev-ui/app/utils/kgDataSourcesCommits.ts @@ -23,31 +23,65 @@ export function commitStatusLabel( return current === remote ? 'matches branch head' : 'new commits on branch' } -export function prepareCommitStatusLabel( - prepared: string | null | undefined, - tracked: string | null | undefined, -): string { - if (!tracked) return 'branch head unknown' - if (!prepared) return 'not prepared yet' - return prepared === tracked ? 'prepared at branch head' : 'new commits to prepare' +/** Commit we have ingested (local HEAD after pull/prepare). */ +export function resolveIngestedHeadCommit(ds: { + clone_head_commit?: string | null + last_prepared_commit?: string | null + ingested_head_commit?: string | null +}): string | null { + if (ds.ingested_head_commit) return ds.ingested_head_commit + return ds.clone_head_commit ?? ds.last_prepared_commit ?? null } -export function needsIngestionPrepare(ds: { - last_prepared_commit?: string | null +/** Remote branch tip from last check (what git pull would reach). */ +export function resolveBranchTipCommit(ds: { tracked_branch_head_commit?: string | null -}): boolean { - const tracked = ds.tracked_branch_head_commit - if (!tracked) return false - return ds.last_prepared_commit !== tracked +}): string | null { + return ds.tracked_branch_head_commit ?? null } -export function isIngestionPreparedAtHead(ds: { - last_prepared_commit?: string | null +/** + * Newest commit on the branch we do not have yet. + * When never ingested, the whole branch tip is unpulled. + */ +export function resolveNewestUnpulledCommit(ds: { + newest_unpulled_commit?: string | null tracked_branch_head_commit?: string | null -}): boolean { - const tracked = ds.tracked_branch_head_commit - const prepared = ds.last_prepared_commit - return !!tracked && !!prepared && prepared === tracked + clone_head_commit?: string | null + last_prepared_commit?: string | null + ingested_head_commit?: string | null +}): string | null { + if (ds.newest_unpulled_commit !== undefined) { + return ds.newest_unpulled_commit + } + const tip = resolveBranchTipCommit(ds) + if (!tip) return null + const ingested = resolveIngestedHeadCommit(ds) + if (!ingested) return tip + return ingested === tip ? null : tip +} + +export function hasUnpulledCommits(ds: Parameters<typeof resolveNewestUnpulledCommit>[0]): boolean { + return resolveNewestUnpulledCommit(ds) !== null +} + +export function unpulledCommitStatusLabel( + unpulled: string | null | undefined, + branchTip: string | null | undefined, +): string { + if (!branchTip) return 'check branch to see remote tip' + if (!unpulled) return 'up to date with branch' + return 'new commit on branch (not ingested yet)' +} + +export function needsIngestionPrepare(ds: Parameters<typeof hasUnpulledCommits>[0]): boolean { + return hasUnpulledCommits(ds) +} + +export function isIngestionPreparedAtHead(ds: Parameters<typeof hasUnpulledCommits>[0]): boolean { + const tip = resolveBranchTipCommit(ds) + const ingested = resolveIngestedHeadCommit(ds) + return !!tip && !!ingested && ingested === tip } export function formatPreparedFileCount(count: number | null | undefined): string { diff --git a/src/dev-ui/app/utils/kgManageWorkspaceHub.ts b/src/dev-ui/app/utils/kgManageWorkspaceHub.ts new file mode 100644 index 000000000..3566df4ed --- /dev/null +++ b/src/dev-ui/app/utils/kgManageWorkspaceHub.ts @@ -0,0 +1,267 @@ +import { cn } from '@/lib/utils' +import { + buildManageStepUrl, + buildSuggestedNextStep, + buildWorkspaceStepCards, + resolveStepDestination, + type SuggestedNextStepView, + type WorkspaceOverviewInputs, + type WorkspaceStepId, +} from '@/utils/kgManageWorkspace' + +export type WorkspaceHubTone = 'success' | 'warning' | 'primary' | 'muted' + +export interface WorkspaceHubTile { + step: number + key: WorkspaceStepId + title: string + subtitle: string + to: string + enabled: boolean + lockedReason: string | null + highlight: boolean + tone: WorkspaceHubTone + linkLabel: string + done: boolean +} + +export interface WorkspaceHubPhaseBadge { + label: string + variant: 'default' | 'secondary' | 'success' | 'warning' +} + +export interface WorkspaceHubOverview extends WorkspaceOverviewInputs { + preparedSourceCount: number + entityTypeLabels: string[] + relationshipTypeLabels: string[] +} + +export interface WorkspaceHubSourceRow { + id: string + name: string + url: string + status: string + statusVariant: 'success' | 'secondary' | 'outline' +} + +function sourcesPhaseComplete(input: WorkspaceHubOverview): boolean { + return input.dataSourceCount > 0 && input.preparedSourceCount === input.dataSourceCount +} + +function designPhaseComplete(input: WorkspaceHubOverview): boolean { + return ( + input.workspaceStatus?.workspace_mode === 'extraction_operations' + || input.workspaceStatus?.transition_eligible === true + ) +} + +export function resolveWorkspaceHubPhaseBadge(input: WorkspaceHubOverview): WorkspaceHubPhaseBadge { + if (designPhaseComplete(input)) { + return { label: 'Operations', variant: 'success' } + } + if (sourcesPhaseComplete(input)) { + return { label: 'Design', variant: 'warning' } + } + return { label: 'Data sources', variant: 'secondary' } +} + +export function resolveSuggestedWorkspaceKey(input: WorkspaceHubOverview): WorkspaceStepId { + if (!sourcesPhaseComplete(input)) return 'data-sources' + if (!designPhaseComplete(input)) return 'graph-management' + if (input.maintenanceReadyCount > 0) return 'maintain' + if (input.mutationLogRunCount === 0) return 'graph-management' + return 'mutation-logs' +} + +export function buildWorkspaceHubTiles(input: WorkspaceHubOverview): WorkspaceHubTile[] { + const cards = buildWorkspaceStepCards(input) + const cardById = Object.fromEntries(cards.map((c) => [c.id, c])) as Record< + WorkspaceStepId, + (typeof cards)[number] + > + const highlightKey = resolveSuggestedWorkspaceKey(input) + const sourcesDone = sourcesPhaseComplete(input) + const designDone = designPhaseComplete(input) + + const dsCard = cardById['data-sources'] + const gmCard = cardById['graph-management'] + const mlCard = cardById['mutation-logs'] + const maintainCard = cardById.maintain + + const toneFor = ( + step: number, + done: boolean, + enabled: boolean, + cardStatus: (typeof cards)[number]['status'], + ): WorkspaceHubTone => { + if (done) return 'success' + if (!enabled) return 'muted' + if (cardStatus === 'needs_attention') return 'warning' + if (highlightKey === (['data-sources', 'graph-management', 'mutation-logs', 'maintain'] as const)[step - 1]) { + return 'primary' + } + return 'muted' + } + + const linkLabelFor = (action: (typeof cards)[number]['actionLabel'], done: boolean) => + action === 'Revisit' || done ? 'Revisit →' : action === 'Run' ? 'Run →' : 'Open →' + + return [ + { + step: 1, + key: 'data-sources', + title: 'Data sources', + subtitle: sourcesDone + ? `${input.dataSourceCount} source${input.dataSourceCount === 1 ? '' : 's'} · ingestion ready` + : input.dataSourceCount > 0 + ? `${input.preparedSourceCount}/${input.dataSourceCount} prepared · finish ingestion` + : 'Connect repositories and prepare ingestion context', + to: resolveStepDestination(input.kgId, 'data-sources', { + dataSourceCount: input.dataSourceCount, + }), + enabled: true, + lockedReason: null, + highlight: highlightKey === 'data-sources', + tone: toneFor(1, sourcesDone, true, dsCard.status), + linkLabel: linkLabelFor(dsCard.actionLabel, sourcesDone), + done: sourcesDone, + }, + { + step: 2, + key: 'graph-management', + title: 'Design', + subtitle: designDone + ? 'Schema validated · extraction operations available' + : sourcesDone + ? 'Design assistant, schema bootstrap, and validation' + : 'Open anytime; prepare data sources to clear later gates', + to: resolveStepDestination(input.kgId, 'graph-management'), + enabled: true, + lockedReason: null, + highlight: highlightKey === 'graph-management', + tone: toneFor(2, designDone, true, gmCard.status), + linkLabel: linkLabelFor(gmCard.actionLabel, designDone), + done: designDone, + }, + { + step: 3, + key: 'mutation-logs', + title: 'Mutation logs', + subtitle: input.mutationLogRunCount > 0 + ? `${input.mutationLogRunCount} run${input.mutationLogRunCount === 1 ? '' : 's'} recorded` + : 'Review extraction and apply runs', + to: resolveStepDestination(input.kgId, 'mutation-logs'), + enabled: input.dataSourceCount > 0, + lockedReason: input.dataSourceCount > 0 ? null : 'Connect a data source before reviewing runs.', + highlight: highlightKey === 'mutation-logs', + tone: toneFor(3, input.mutationLogRunCount > 0, input.dataSourceCount > 0, mlCard.status), + linkLabel: linkLabelFor(mlCard.actionLabel, input.mutationLogRunCount > 0), + done: input.mutationLogRunCount > 0, + }, + { + step: 4, + key: 'maintain', + title: 'Maintain', + subtitle: input.maintenanceReadyCount > 0 + ? `${input.maintenanceReadyCount} source${input.maintenanceReadyCount === 1 ? '' : 's'} need maintenance` + : 'Incremental graph updates from new commits', + to: resolveStepDestination(input.kgId, 'maintain'), + enabled: designDone, + lockedReason: designDone ? null : 'Complete design validation before maintenance.', + highlight: highlightKey === 'maintain', + tone: toneFor(4, maintainCard.status === 'ready' && input.maintenanceReadyCount === 0, designDone, maintainCard.status), + linkLabel: linkLabelFor(maintainCard.actionLabel, maintainCard.status === 'ready' && input.maintenanceReadyCount === 0), + done: maintainCard.status === 'ready' && input.maintenanceReadyCount === 0 && input.dataSourceCount > 0, + }, + ] +} + +export function buildWorkspaceHubNextStep(input: WorkspaceHubOverview): { + to: string + title: string + description: string + label: string + primaryPhase: boolean +} { + const next = buildSuggestedNextStep(input) + const actionWord = + next.actionLabel === 'Run' + ? 'Run' + : next.actionLabel === 'Revisit' + ? 'Revisit' + : 'Open' + return { + to: resolveStepDestination(input.kgId, next.stepId, { + dataSourceCount: input.dataSourceCount, + }), + title: next.title, + description: next.description, + label: `${actionWord} ${next.title}`, + primaryPhase: !sourcesPhaseComplete(input), + } +} + +export function workspaceHubTileClasses(item: { + enabled: boolean + highlight: boolean + tone: WorkspaceHubTone +}): string { + if (!item.enabled) return '' + const { tone, highlight } = item + if (tone === 'success') { + return cn( + 'border-green-500/35 bg-green-500/5 dark:border-green-500/25 dark:bg-green-950/20', + highlight && 'ring-1 ring-green-500/30', + ) + } + if (tone === 'warning') { + return cn( + 'border-amber-500/40 bg-amber-500/5 dark:border-amber-500/30 dark:bg-amber-950/25', + highlight && 'ring-1 ring-amber-500/25', + ) + } + if (tone === 'primary') { + return cn( + 'border-primary/45 bg-primary/10 ring-1 ring-primary/20', + highlight && 'ring-2 ring-primary/35', + ) + } + return cn( + 'border-border bg-card', + highlight && 'border-primary/50 bg-primary/10 ring-1 ring-primary/20', + ) +} + +export function workspaceHubStepBadgeClass(item: { + enabled: boolean + done: boolean + tone: WorkspaceHubTone +}): string { + if (!item.enabled) { + return 'flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground' + } + if (item.done) { + return 'flex size-7 shrink-0 items-center justify-center rounded-full bg-green-500 text-white' + } + if (item.tone === 'warning') { + return 'flex size-7 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white dark:bg-amber-500' + } + if (item.tone === 'primary') { + return 'flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground' + } + return 'flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground' +} + +export function workspaceHubDescription(input: WorkspaceHubOverview): string { + if (!sourcesPhaseComplete(input)) { + return 'Finish ingestion under Data sources, then continue through Design. Green tiles mark completed gates; the highlighted tile is your current focus.' + } + if (!designPhaseComplete(input)) { + return 'Use Design for the assistant and schema bootstrap. Green tiles use Revisit; the highlighted tile is your suggested next step.' + } + return 'Continue with mutation logs or maintenance, or Revisit any completed step below.' +} + +export function buildManageOverviewUrl(kgId: string): string { + return buildManageStepUrl(kgId) +} From 81e059526c170d53052743708b8883fe626d8af2 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Thu, 28 May 2026 22:37:27 -0400 Subject: [PATCH 60/80] docs(extraction): sticky session chat specs (#738) (#743) * feat(ui): align graph management step with k-extract phase2 layout Rework the design chat, schema/session panels, and mode switcher with locked extraction modes until the workspace transitions to extraction operations. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ui): rename graph management chat title to Graph Management Assistant Co-authored-by: Cursor <cursoragent@cursor.com> * docs(extraction): specify sticky session chat turns and runtime Document Graph Management chat as NDJSON streaming turns inside sticky Claude Agent SDK containers with JobPackage gating and UI mode skills. Closes #738 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> --- specs/extraction/agent-sessions.spec.md | 14 + specs/extraction/chat-turns.spec.md | 81 ++++ specs/extraction/operations.spec.md | 19 + .../extraction/sticky-session-runtime.spec.md | 50 ++ specs/index.spec.md | 2 + specs/nfr/workload-execution.spec.md | 14 + .../extraction/SharedConversationPanel.vue | 351 ++++++++++---- .../pages/knowledge-graphs/[kgId]/manage.vue | 451 ++++++++++-------- .../kg-graph-management-artifacts.test.ts | 40 ++ .../tests/kg-graph-management-modes.test.ts | 47 ++ .../knowledge-graph-manage-workspace.test.ts | 47 +- src/dev-ui/app/utils/kgGraphManagement.ts | 34 ++ .../app/utils/kgGraphManagementArtifacts.ts | 55 +++ 13 files changed, 900 insertions(+), 305 deletions(-) create mode 100644 specs/extraction/chat-turns.spec.md create mode 100644 specs/extraction/sticky-session-runtime.spec.md create mode 100644 src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts create mode 100644 src/dev-ui/app/tests/kg-graph-management-modes.test.ts create mode 100644 src/dev-ui/app/utils/kgGraphManagementArtifacts.ts diff --git a/specs/extraction/agent-sessions.spec.md b/specs/extraction/agent-sessions.spec.md index 020b1f0f7..4f2e44a7b 100644 --- a/specs/extraction/agent-sessions.spec.md +++ b/specs/extraction/agent-sessions.spec.md @@ -27,6 +27,20 @@ The system SHALL keep sessions active until explicit reset. - WHEN the user sends follow-up messages over time - THEN prior session context remains available for continued conversation +#### Scenario: Chat turn persistence +- GIVEN a completed graph-management chat turn +- WHEN the assistant reply is emitted +- THEN user and assistant messages are persisted on the session +- AND sticky runtime metadata is updated on the session runtime context + +### Requirement: Sticky Runtime Association +The system SHALL associate active sessions with sticky container runtime leases. + +#### Scenario: Runtime metadata on session +- GIVEN a chat turn starts a or reuses a sticky container +- WHEN the turn is accepted +- THEN session runtime context records sticky container identity and status + ### Requirement: Clear Chat Reset The system SHALL provide an explicit "Clear chat" action that resets runtime context. diff --git a/specs/extraction/chat-turns.spec.md b/specs/extraction/chat-turns.spec.md new file mode 100644 index 000000000..f5679f9f9 --- /dev/null +++ b/specs/extraction/chat-turns.spec.md @@ -0,0 +1,81 @@ +# Chat Turns + +## Purpose +Graph Management chat turns orchestrate conversational extraction agent workloads inside sticky session containers. Each turn persists user and assistant messages, streams transparent activity to the UI, and gates execution until ingestion context (JobPackage) is available when required by the active graph-management mode. + +## Requirements + +### Requirement: Sticky Session Container Execution +The system SHALL execute graph-management chat turns in a sticky session container assigned to the active extraction agent session. + +#### Scenario: Reuse sticky runtime across turns +- GIVEN an active extraction agent session with a running sticky container +- WHEN the user sends a follow-up chat message +- THEN the same sticky container lease is reused until clear-chat, timeout, or reset + +#### Scenario: Start sticky runtime on first turn +- GIVEN an active session without a sticky container lease +- WHEN the user sends the first chat message +- THEN the system starts a sticky session container for that session scope +- AND records container identity in session runtime context + +### Requirement: JobPackage Context in Sticky Runtime +The system SHALL load ingestion context from JobPackage archives into the sticky session container when JobPackage access is required. + +#### Scenario: JobPackage required for extraction jobs mode +- GIVEN graph-management UI mode `Extraction Jobs` +- AND at least one data source exists for the knowledge graph +- WHEN JobPackage context is not yet prepared for all tracked sources +- THEN the chat turn enters a wait state instead of invoking the agent +- AND the UI receives wait-phase activity explaining that ingestion context is pending + +#### Scenario: JobPackage ready +- GIVEN graph-management UI mode `Extraction Jobs` +- AND prepared ingestion context exists for the knowledge graph +- WHEN the user sends a chat message +- THEN JobPackage material is available to the sticky container agent runtime +- AND the agent turn proceeds normally + +#### Scenario: Schema design without JobPackage gate +- GIVEN graph-management UI mode `Initial Schema Design` +- WHEN the user sends a chat message +- THEN JobPackage readiness is not required to start the agent turn +- AND schema-bootstrap skills remain primary framing + +### Requirement: Mode-Aware Skill Framing +The system SHALL resolve agent skills using workspace session mode and graph-management UI mode. + +#### Scenario: Three UI mode skill overlays +- GIVEN graph-management UI modes `Initial Schema Design`, `Extraction Jobs`, and `One-off Mutations` +- WHEN a chat turn starts +- THEN skill framing reflects the selected UI mode +- AND global templates plus knowledge-graph overrides still apply underneath + +### Requirement: Streaming Chat Turn Contract +The system SHALL expose chat turns over an NDJSON streaming HTTP endpoint. + +#### Scenario: Thinking transparency +- GIVEN an in-progress chat turn +- WHEN the agent performs preparatory work +- THEN the stream emits `thinking` events with recent activity lines for UI display + +#### Scenario: Wait transparency +- GIVEN JobPackage context is required but unavailable +- WHEN the user sends a chat message +- THEN the stream emits a `wait` event with phase `awaiting_job_package` +- AND completes with an assistant explanation of the wait condition + +#### Scenario: Successful completion +- GIVEN an agent turn completes successfully +- WHEN the stream finishes +- THEN a terminal `done` event includes the assistant reply +- AND user and assistant messages are persisted on the session + +### Requirement: Clear Chat Resets Runtime +The system SHALL reset sticky session runtime when clear-chat is invoked. + +#### Scenario: Clear chat terminates sticky container +- GIVEN an active session with sticky runtime state +- WHEN the user clicks Clear chat +- THEN the sticky container is reset +- AND a new clean session is started for the same scope diff --git a/specs/extraction/operations.spec.md b/specs/extraction/operations.spec.md index 40110e1ee..adb760f35 100644 --- a/specs/extraction/operations.spec.md +++ b/specs/extraction/operations.spec.md @@ -20,6 +20,25 @@ The system SHALL provide different default skill sets for bootstrap and extracti - THEN the default skill set is extraction-job-setup and minor-direct-edit oriented - AND schema edit skills remain available but are not the primary framing +### Requirement: Graph Management UI Mode Overlays +The system SHALL apply graph-management UI mode overlays on top of workspace session mode skills. + +#### Scenario: Initial schema design overlay +- GIVEN graph-management UI mode `Initial Schema Design` +- WHEN a chat turn resolves skills +- THEN schema bootstrap and validation guidance is primary + +#### Scenario: Extraction jobs overlay +- GIVEN graph-management UI mode `Extraction Jobs` +- WHEN a chat turn resolves skills +- THEN extraction job setup and sync-run guidance is primary +- AND JobPackage readiness is required before agent execution + +#### Scenario: One-off mutations overlay +- GIVEN graph-management UI mode `One-off Mutations` +- WHEN a chat turn resolves skills +- THEN scoped JSONL mutation authoring guidance is primary + ### Requirement: Skill Resolution Model The system SHALL resolve agent skills using global templates with knowledge-graph overrides. diff --git a/specs/extraction/sticky-session-runtime.spec.md b/specs/extraction/sticky-session-runtime.spec.md new file mode 100644 index 000000000..b287c1948 --- /dev/null +++ b/specs/extraction/sticky-session-runtime.spec.md @@ -0,0 +1,50 @@ +# Sticky Session Runtime + +## Purpose +Sticky session runtimes host long-lived Claude Agent SDK workloads for Graph Management chat. Each active extraction agent session receives an isolated container with mounted skills, scoped credentials, and optional JobPackage materialization for repository access. + +## Requirements + +### Requirement: Isolated Sticky Container per Session +The system SHALL run each active graph-management chat session in an isolated container. + +#### Scenario: Session-scoped isolation +- GIVEN two users with active sessions on the same knowledge graph +- WHEN both send chat messages +- THEN each session uses a distinct sticky container lease +- AND container labels include session, user, knowledge graph, and mode identifiers + +### Requirement: Claude Agent SDK Runtime +The system SHALL host Claude Agent SDK agent instances inside sticky session containers. + +#### Scenario: Agent runtime image +- GIVEN a sticky session container starts +- WHEN the container initializes +- THEN it runs an agent runtime process capable of Claude Agent SDK execution +- AND is distinct from ephemeral JobPackage worker containers used for sync extraction + +### Requirement: Skills Directory Mount +The system SHALL mount the platform skills directory into sticky session containers. + +#### Scenario: Skills available at runtime +- GIVEN a sticky session container starts +- WHEN the agent runtime initializes +- THEN SKILL.md resources from the platform skills directory are readable inside the container + +### Requirement: JobPackage Materialization +The system SHALL materialize JobPackage archives into sticky session containers when ingestion context is required. + +#### Scenario: Repository files available +- GIVEN JobPackage context is ready for the knowledge graph +- WHEN a sticky container starts or refreshes ingestion context +- THEN manifest, changeset, content, and reconstructed repository files are available under the session work directory +- AND the agent can inspect data-source content without leaving the container + +### Requirement: Scoped Runtime Credentials +The system SHALL inject short-lived credentials into sticky session containers using least-privilege tenant and knowledge-graph scope. + +#### Scenario: Credential injection at start +- GIVEN a sticky session container is started +- WHEN runtime credentials are issued +- THEN credentials are injected as environment variables or runtime files +- AND credentials are never persisted in mutation logs or session message history diff --git a/specs/index.spec.md b/specs/index.spec.md index 5cce73fa4..cee23c82a 100644 --- a/specs/index.spec.md +++ b/specs/index.spec.md @@ -65,6 +65,8 @@ AI-assisted schema and extraction workflows that emit MutationLogs for Graph app |------|-------| | [Operations](extraction/operations.spec.md) | Mode-specific agent operations and mutation-log production | | [Agent Sessions](extraction/agent-sessions.spec.md) | Session lifecycle, reset behavior, and session metrics | +| [Chat Turns](extraction/chat-turns.spec.md) | Graph-management chat streaming, wait states, and turn persistence | +| [Sticky Session Runtime](extraction/sticky-session-runtime.spec.md) | Isolated sticky containers, JobPackage context, Claude Agent SDK runtime | ### [Shared Kernel](shared-kernel/) — Cross-Cutting Contracts Capabilities shared across bounded contexts. diff --git a/specs/nfr/workload-execution.spec.md b/specs/nfr/workload-execution.spec.md index ce2149997..ed11899c5 100644 --- a/specs/nfr/workload-execution.spec.md +++ b/specs/nfr/workload-execution.spec.md @@ -57,3 +57,17 @@ The system SHALL provide required runtime context in workload containers. - THEN ingestion context resources and repository files needed for processing are available - AND the skills directory is available to the agent runtime +#### Scenario: Sticky session Claude agent runtime +- GIVEN a sticky session container for graph-management chat +- WHEN the container starts +- THEN it hosts a Claude Agent SDK agent instance isolated from the API process +- AND JobPackage material may be mounted when ingestion context is required for the active graph-management mode + +### Requirement: Graph Management UI Mode Skills +The system SHALL expose graph-management UI mode skill overlays in addition to workspace session mode skills. + +#### Scenario: UI mode overlays +- GIVEN graph-management modes `Initial Schema Design`, `Extraction Jobs`, and `One-off Mutations` +- WHEN skill instructions are resolved for a chat turn +- THEN UI mode overlays adjust assistant framing while preserving workspace session mode guardrails + diff --git a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue index bbe2edcf5..2fb7482ab 100644 --- a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue +++ b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue @@ -1,9 +1,8 @@ <script setup lang="ts"> -import { computed, nextTick, ref, watch } from 'vue' -import { Loader2, RefreshCw, SendHorizontal } from 'lucide-vue-next' +import { computed, nextTick, onMounted, ref, watch } from 'vue' +import { Bot, Loader2, RefreshCw, RotateCcw, Send, Sparkles, User } from 'lucide-vue-next' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { Textarea } from '@/components/ui/textarea' import { AlertDialog, AlertDialogAction, @@ -14,7 +13,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { handleChatInputKeydown } from '@/utils/kgManageState' interface ConversationEntry { role?: string @@ -41,6 +39,9 @@ const props = withDefaults(defineProps<{ inputDisabledReason?: string | null forbidden?: boolean forbiddenReason?: string | null + title?: string + description?: string + footerHint?: string }>(), { loading: false, clearing: false, @@ -53,6 +54,11 @@ const props = withDefaults(defineProps<{ inputDisabledReason: null, forbidden: false, forbiddenReason: null, + title: 'Graph Management Assistant', + description: + 'Design and refine schema readiness, validation, and extraction operations for this knowledge graph. Use the assistant below to drive workspace changes.', + footerHint: + 'Use Schema & artifacts and Session pointers below to inspect workspace state; send notes or questions here.', }) const emit = defineEmits<{ @@ -63,33 +69,80 @@ const emit = defineEmits<{ }>() const clearConfirmOpen = ref(false) -const timelineRef = ref<HTMLElement | null>(null) +const chatScrollRef = ref<HTMLElement | null>(null) +const textareaRef = ref<HTMLTextAreaElement | null>(null) +const composerInputId = 'graph-management-chat-input' const messageHistory = computed(() => props.session?.message_history ?? []) -const activityTimeline = computed(() => props.activityLines) - -const combinedTimelineLength = computed( - () => messageHistory.value.length + activityTimeline.value.length, -) const chatInputDisabled = computed( () => props.loading || props.clearing || props.sending || props.inputDisabled || props.forbidden, ) -const chatInputHelp = computed(() => { - if (props.forbidden) { - return props.forbiddenReason ?? 'Chat is unavailable because you lack permission for this action.' - } - if (props.inputDisabledReason) return props.inputDisabledReason - return 'Press Enter to send. Shift+Enter adds a new line.' +const thinkingDisplaySlots = computed(() => { + const src = props.activityLines.filter(Boolean) + if (src.length === 0) return [''] + return src.slice(-3) }) -watch(combinedTimelineLength, async () => { - await nextTick() - if (timelineRef.value) { - timelineRef.value.scrollTop = timelineRef.value.scrollHeight +function isUserRole(role: string | undefined): boolean { + return role === 'user' || role === 'human' +} + +function messageText(entry: ConversationEntry): string { + return entry.content ?? entry.message ?? '(empty)' +} + +function scrollToBottom() { + const el = chatScrollRef.value + if (el) { + el.scrollTop = el.scrollHeight } -}) +} + +function adjustTextareaHeight() { + const el = textareaRef.value + if (!el) return + const lh = parseFloat(getComputedStyle(el).lineHeight) + const line = Number.isFinite(lh) && lh > 0 ? lh : 21 + const minH = Math.round(line * 2.5) + const maxH = Math.round(line * 14) + el.style.height = '0' + const scrollH = el.scrollHeight + const h = Math.min(Math.max(scrollH, minH), maxH) + el.style.height = `${h}px` + el.style.overflowY = scrollH > maxH ? 'auto' : 'hidden' +} + +function handleComposerEnter(event: KeyboardEvent) { + if (event.shiftKey) return + if (chatInputDisabled.value || !props.draftMessage.trim()) return + event.preventDefault() + sendDraftMessage() +} + +function renderAssistantHtml(text: string): string { + let s = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + s = s.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-foreground">$1</strong>') + s = s.replace( + /`([^`]+)`/g, + '<code class="rounded bg-muted px-1 py-0.5 text-xs font-mono text-foreground">$1</code>', + ) + s = s.replace( + /^> (.+)$/gm, + '<p class="my-2 border-l-2 border-amber-500/60 pl-3 text-sm text-muted-foreground italic">$1</p>', + ) + s = s.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '<a class="text-primary font-medium underline underline-offset-2 hover:text-primary/90" href="$2">$1</a>', + ) + s = s.replace(/## (.+)$/gm, '<h3 class="text-base font-semibold mt-3 mb-1 text-foreground">$1</h3>') + s = s.replace(/### (.+)$/gm, '<h4 class="text-sm font-semibold mt-2 text-foreground">$1</h4>') + s = s.replace(/^---$/gm, '<hr class="my-3 border-border" />') + s = s.replace(/\n\n+/g, '<br /><br />') + s = s.replace(/\n/g, '<br />') + return s +} function confirmClearChat() { clearConfirmOpen.value = false @@ -101,109 +154,227 @@ function sendDraftMessage() { if (!trimmed || chatInputDisabled.value) return emit('sendMessage', trimmed) emit('update:draftMessage', '') + void nextTick(() => adjustTextareaHeight()) } -function onChatInputKeydown(event: KeyboardEvent) { - handleChatInputKeydown(event, sendDraftMessage) -} +watch( + () => [messageHistory.value.length, props.activityLines.length, props.sending], + async () => { + await nextTick() + scrollToBottom() + }, +) + +watch( + () => props.draftMessage, + () => { + void nextTick(() => adjustTextareaHeight()) + }, +) + +watch( + () => props.loading, + (busy) => { + if (!busy) void nextTick(() => adjustTextareaHeight()) + }, +) + +onMounted(() => { + void nextTick(() => adjustTextareaHeight()) +}) </script> <template> - <Card> - <CardHeader> - <div class="flex flex-wrap items-start justify-between gap-2"> - <div> - <CardTitle class="text-base">Conversation</CardTitle> - <CardDescription> - Shared conversation feed for {{ modeLabel }} with server-side session resume. + <Card + id="graph-management-design-assistant" + class="overflow-hidden border-2 border-primary/25 shadow-md scroll-mt-6" + > + <CardHeader class="border-b bg-muted/30 pb-4"> + <div class="flex flex-wrap items-start gap-3"> + <div + class="flex size-10 shrink-0 items-center justify-center rounded-full border border-primary/30 bg-primary/10 text-primary" + > + <Sparkles class="size-5" aria-hidden="true" /> + </div> + <div class="min-w-0 flex-1 space-y-1"> + <CardTitle class="text-lg leading-tight">{{ title }}</CardTitle> + <CardDescription class="text-sm leading-relaxed"> + {{ description }} </CardDescription> + <p class="text-xs text-muted-foreground"> + Mode: + <span class="font-medium text-foreground">{{ modeLabel }}</span> + · Session: + <span class="font-medium text-foreground">{{ sessionStatusLabel }}</span> + </p> + </div> + <div class="flex shrink-0 flex-wrap gap-2"> + <Button + type="button" + size="sm" + variant="outline" + class="gap-1.5" + :disabled="loading" + @click="emit('refresh')" + > + <RefreshCw class="size-4" /> + Resume session + </Button> + <Button + type="button" + size="sm" + variant="outline" + class="gap-1.5" + :disabled="clearing || loading || forbidden" + @click="clearConfirmOpen = true" + > + <Loader2 v-if="clearing" class="size-4 animate-spin" /> + <RotateCcw v-else class="size-4" /> + Clear chat + </Button> </div> - <p class="text-xs text-muted-foreground"> - Session: <span class="font-medium text-foreground">{{ sessionStatusLabel }}</span> - </p> </div> </CardHeader> - <CardContent class="space-y-3"> + + <CardContent class="p-0"> <div v-if="forbidden" - class="rounded border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive" + class="border-b border-destructive/40 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-6" role="alert" > {{ forbiddenReason ?? 'You do not have permission to use graph management chat for this knowledge graph.' }} </div> - <div class="flex items-center justify-between"> - <p class="text-xs text-muted-foreground">No local cache: conversation state is server-side only.</p> - <Button size="sm" variant="ghost" class="h-7 px-2 text-[11px]" :disabled="loading" @click="emit('refresh')"> - <RefreshCw class="mr-1 size-3.5" /> - Resume session - </Button> - </div> - - <div v-if="loading" class="flex items-center gap-2 text-xs text-muted-foreground"> - <Loader2 class="size-3.5 animate-spin" /> - Loading active conversation session... - </div> <div - v-else - ref="timelineRef" - class="space-y-2 max-h-56 overflow-auto rounded border p-2" + ref="chatScrollRef" + class="min-h-[14rem] max-h-[min(32rem,60vh)] space-y-4 overflow-y-auto bg-muted/10 px-4 py-4 sm:px-6" > <div - v-for="(entry, idx) in messageHistory" - :key="`msg-${idx}-${entry.role ?? 'unknown'}`" - class="rounded px-2 py-1 text-xs" - :class="entry.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'" + v-if="loading" + class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground" + aria-busy="true" + aria-live="polite" > - <p class="mb-0.5 font-medium">{{ entry.role ?? 'system' }}</p> - <p>{{ entry.content ?? entry.message ?? '(empty)' }}</p> + <Loader2 class="size-8 shrink-0 animate-spin" /> + <p class="text-center text-sm text-foreground/80">Loading conversation session…</p> </div> + <template v-else> + <div + v-for="(entry, idx) in messageHistory" + :key="`msg-${idx}-${entry.role ?? 'unknown'}`" + class="flex gap-3" + :class="isUserRole(entry.role) ? 'flex-row-reverse' : ''" + > + <div + class="flex size-9 shrink-0 items-center justify-center rounded-full border bg-card" + :class=" + isUserRole(entry.role) + ? 'border-primary/35 bg-primary/12 text-primary' + : 'border-slate-300/60 bg-slate-100/80 text-slate-600 dark:border-slate-700/60 dark:bg-slate-900/70 dark:text-slate-300' + " + > + <User v-if="isUserRole(entry.role)" class="size-4 text-primary" /> + <Bot v-else class="size-4 text-muted-foreground" /> + </div> + <div + class="min-w-0 max-w-[min(100%,42rem)] rounded-2xl border px-4 py-3 text-sm leading-relaxed shadow-sm" + :class=" + isUserRole(entry.role) + ? 'border-primary/25 bg-primary/[0.07] text-foreground shadow-primary/5' + : 'border-slate-300/65 bg-slate-50/95 text-foreground shadow-slate-300/20 dark:border-slate-700/70 dark:bg-slate-900/65 dark:shadow-black/20' + " + > + <p v-if="isUserRole(entry.role)" class="whitespace-pre-wrap break-words"> + {{ messageText(entry) }} + </p> + <div + v-else + class="chat-md space-y-1 break-words [&_a]:break-all [&_code]:break-all" + v-html="renderAssistantHtml(messageText(entry))" + /> + </div> + </div> - <div - v-for="(line, idx) in activityTimeline" - :key="`activity-${idx}`" - class="rounded border border-dashed px-2 py-1 text-xs text-muted-foreground" - > - {{ line }} - </div> + <div + v-if="sending" + class="flex gap-3 text-muted-foreground" + aria-live="polite" + aria-busy="true" + > + <div class="flex size-9 shrink-0 items-center justify-center rounded-full border bg-card"> + <Bot class="size-4" /> + </div> + <div + class="min-w-0 max-w-[min(100%,42rem)] flex-1 overflow-hidden rounded-2xl border border-dashed border-primary/25 bg-gradient-to-b from-slate-50/90 via-card to-card px-4 py-3 text-sm shadow-sm dark:from-slate-900/65" + > + <div class="mb-2 flex items-center gap-2 text-foreground"> + <Loader2 class="size-4 shrink-0 animate-spin text-primary" aria-hidden="true" /> + <span class="font-medium tracking-tight">Thinking...</span> + </div> + <ol class="m-0 list-none space-y-2 border-l-2 border-primary/25 pl-3"> + <li + v-for="(line, lineIdx) in thinkingDisplaySlots" + :key="`${lineIdx}-${line || 'empty'}`" + class="flex gap-2 text-xs leading-snug" + > + <span + class="w-4 shrink-0 select-none pt-0.5 text-center font-mono text-xs text-primary/45" + aria-hidden="true" + > + – + </span> + <span + class="min-w-0 flex-1 break-words font-mono text-[13px]" + :class="line ? 'text-foreground/90' : 'text-muted-foreground/35'" + > + {{ line || '—' }} + </span> + </li> + </ol> + </div> + </div> - <p - v-if="messageHistory.length === 0 && activityTimeline.length === 0" - class="text-xs text-muted-foreground" - > - No messages yet. Send a prompt or use validate/transition actions to drive session activity. - </p> + <p + v-if="messageHistory.length === 0 && !sending" + class="py-8 text-center text-sm text-muted-foreground" + > + No messages yet. Send a prompt or use validate/transition actions to drive session activity. + </p> + </template> </div> - <div class="space-y-2"> - <div class="flex items-start gap-2"> - <Textarea - :model-value="draftMessage" + <div class="border-t bg-muted/20 p-4 sm:p-6"> + <label class="sr-only" :for="composerInputId">Message to graph management assistant</label> + <div class="flex flex-col gap-3 sm:flex-row sm:items-end"> + <textarea + :id="composerInputId" + ref="textareaRef" + :value="draftMessage" + rows="1" :disabled="chatInputDisabled" :placeholder="inputPlaceholder" - class="min-h-20" - aria-label="Graph management chat input" - @update:model-value="(value) => emit('update:draftMessage', value)" - @keydown="onChatInputKeydown" + class="w-full flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" + @input="emit('update:draftMessage', ($event.target as HTMLTextAreaElement).value)" + @keydown.enter="handleComposerEnter" /> <Button - variant="default" - class="shrink-0" + type="button" + class="h-10 min-h-10 w-full shrink-0 sm:w-auto sm:px-6" :disabled="chatInputDisabled || !draftMessage.trim()" - :title="chatInputHelp" + :title="inputDisabledReason ?? undefined" @click="sendDraftMessage" > - <Loader2 v-if="sending" class="size-3.5 animate-spin" /> - <SendHorizontal v-else class="size-3.5" /> - </Button> - </div> - <div class="flex flex-wrap items-center justify-between gap-2"> - <p class="text-[11px] text-muted-foreground">{{ chatInputHelp }}</p> - <Button variant="outline" :disabled="clearing || loading || forbidden" @click="clearConfirmOpen = true"> - <Loader2 v-if="clearing" class="mr-1.5 size-3.5 animate-spin" /> - Clear chat + <Loader2 v-if="sending" class="size-4 animate-spin" /> + <template v-else> + <Send class="size-4 sm:mr-2" /> + <span class="hidden sm:inline">Send</span> + </template> </Button> </div> + <p class="mt-2 text-xs text-muted-foreground"> + {{ footerHint }} + <span class="text-muted-foreground/90"> · Enter to send, Shift+Enter for a new line.</span> + </p> </div> </CardContent> </Card> diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 3b3fd268d..2f9e65609 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -16,6 +16,7 @@ import { Loader2, Lock, MessageSquare, + PencilRuler, PlayCircle, ScrollText, ShieldAlert, @@ -44,17 +45,25 @@ import { buildGraphManagementRailItems, buildGraphManagementStepUrl, filterRailItemsForMode, + graphManagementModeLockReason, + isGraphManagementModeUnlocked, parseGraphManagementModeQuery, - resolveDefaultGraphManagementMode, - resolveRailSelectionForMode, + resolveEffectiveGraphManagementMode, resolveSharedSessionMode, type GraphManagementMode, + type GraphManagementModeGateInput, type GraphManagementRailItemId, } from '@/utils/kgGraphManagement' +import { + filterSchemaRailItems, + graphManagementArtifactHint, + graphManagementArtifactRowClass, + graphManagementRailItemDone, + resolveSchemaRailSelection, +} from '@/utils/kgGraphManagementArtifacts' import { buildManageStepUrl, parseManageStepQuery, - stepStatusTintClass, } from '@/utils/kgManageWorkspace' import { buildWorkspaceHubNextStep, @@ -311,10 +320,27 @@ const visibleRailItems = computed(() => filterRailItemsForMode(graphManagementRailItems.value, graphManagementMode.value), ) -const selectedRailItem = computed(() => - visibleRailItems.value.find((item) => item.id === selectedRailItemId.value) ?? null, +const schemaRailItems = computed(() => filterSchemaRailItems(visibleRailItems.value)) + +const selectedSchemaRailItem = computed(() => + schemaRailItems.value.find((item) => item.id === selectedRailItemId.value) ?? null, ) +const graphManagementModeGate = computed((): GraphManagementModeGateInput => ({ + workspaceMode: statusProjection.value?.workspace_mode ?? 'schema_bootstrap', + transitionEligible: statusProjection.value?.transition_eligible === true, +})) + +const graphManagementChatDescription = computed(() => { + if (graphManagementMode.value === 'extraction-jobs') { + return 'Coordinate extraction job setup, sync runs, and maintenance for this knowledge graph. Use the assistant below to drive operational changes.' + } + if (graphManagementMode.value === 'one-off-mutations') { + return 'Author and apply one-off graph mutations scoped to this knowledge graph. Use the assistant below for mutation guidance and workspace context.' + } + return 'Design and refine schema readiness, validation, and bootstrap transition for this knowledge graph. Use the assistant below to prepare workspace artifacts.' +}) + const canTransition = computed(() => statusProjection.value?.workspace_mode === 'schema_bootstrap' && statusProjection.value?.transition_eligible === true, @@ -795,11 +821,15 @@ async function loadSessionHistory() { function syncGraphManagementState() { if (activeStep.value !== 'graph-management') return const fromQuery = parseGraphManagementModeQuery(route.query.gm_mode) - graphManagementMode.value = fromQuery - ?? resolveDefaultGraphManagementMode( - statusProjection.value?.workspace_mode ?? 'schema_bootstrap', - ) - selectedRailItemId.value = resolveRailSelectionForMode( + const effectiveMode = resolveEffectiveGraphManagementMode( + fromQuery, + graphManagementModeGate.value, + ) + graphManagementMode.value = effectiveMode + if (fromQuery && fromQuery !== effectiveMode) { + navigateTo(buildGraphManagementStepUrl(kgId.value, effectiveMode), { replace: true }) + } + selectedRailItemId.value = resolveSchemaRailSelection( selectedRailItemId.value, graphManagementMode.value, graphManagementRailItems.value, @@ -807,8 +837,13 @@ function syncGraphManagementState() { } function setGraphManagementMode(mode: GraphManagementMode) { + if (!isGraphManagementModeUnlocked(mode, graphManagementModeGate.value)) { + const reason = graphManagementModeLockReason(mode, graphManagementModeGate.value) + toast.message('Mode locked', { description: reason ?? 'Finish schema design first.' }) + return + } graphManagementMode.value = mode - selectedRailItemId.value = resolveRailSelectionForMode( + selectedRailItemId.value = resolveSchemaRailSelection( selectedRailItemId.value, mode, graphManagementRailItems.value, @@ -816,12 +851,12 @@ function setGraphManagementMode(mode: GraphManagementMode) { navigateTo(buildGraphManagementStepUrl(kgId.value, mode), { replace: true }) } -function selectRailItem(itemId: GraphManagementRailItemId) { +function selectSchemaRailItem(itemId: GraphManagementRailItemId) { selectedRailItemId.value = itemId } -function onRailKeydown(event: KeyboardEvent, itemId: GraphManagementRailItemId) { - handleActivatableKeydown(event, () => selectRailItem(itemId)) +function onSchemaRailKeydown(event: KeyboardEvent, itemId: GraphManagementRailItemId) { + handleActivatableKeydown(event, () => selectSchemaRailItem(itemId)) } function onModeSwitchKeydown(event: KeyboardEvent, mode: GraphManagementMode) { @@ -1515,56 +1550,76 @@ watch(selectedOpsDataSourceId, () => { </Button> </div> - <Card class="graph-management-controls"> - <CardHeader class="pb-3"> - <CardTitle class="text-base">Graph Management</CardTitle> - <CardDescription> - Shared chat session with mode-specific assistant framing and workspace panels. - </CardDescription> - </CardHeader> - <CardContent class="space-y-3"> - <div - class="flex flex-wrap gap-2" - role="tablist" - aria-label="Graph management modes" - > - <Button - v-for="mode in GRAPH_MANAGEMENT_MODE_ORDER" - :key="mode" - size="sm" - role="tab" - :aria-selected="graphManagementMode === mode" - tabindex="0" - :variant="graphManagementMode === mode ? 'default' : 'outline'" - @click="setGraphManagementMode(mode)" - @keydown="onModeSwitchKeydown($event, mode)" + <Card class="graph-management-controls overflow-hidden"> + <CardHeader class="space-y-4 pb-4"> + <div class="flex flex-wrap items-start gap-3"> + <div + class="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-primary/10 text-primary" > - {{ GRAPH_MANAGEMENT_MODE_LABELS[mode] }} - </Button> + <PencilRuler class="size-5 shrink-0" aria-hidden="true" /> + </div> + <div class="min-w-0 flex-1 space-y-1"> + <CardTitle class="text-xl leading-tight">Graph Management</CardTitle> + <CardDescription> + Shared chat session with mode-specific assistant framing and workspace panels. + </CardDescription> + </div> </div> - <div class="flex flex-wrap items-center gap-2"> - <Badge variant="outline">{{ sessionStatusLabel }}</Badge> - <Button - variant="outline" - size="sm" - :disabled="validating || transitioning || workspaceForbidden" - :title="workspaceForbiddenReason ?? undefined" - @click="validateWorkspace" + + <div class="space-y-2"> + <p class="text-sm font-medium text-muted-foreground">Mode:</p> + <div + class="grid gap-2 sm:grid-cols-3" + role="tablist" + aria-label="Graph management modes" > - <Loader2 v-if="validating" class="mr-1.5 size-3.5 animate-spin" /> - <CheckCircle2 v-else class="mr-1.5 size-3.5" /> - Validate - </Button> - <Badge :variant="canTransition ? 'default' : 'secondary'"> - {{ canTransition ? 'Transition eligible' : 'Transition blocked' }} - </Badge> + <template v-for="mode in GRAPH_MANAGEMENT_MODE_ORDER" :key="mode"> + <Button + v-if="isGraphManagementModeUnlocked(mode, graphManagementModeGate)" + size="sm" + variant="outline" + class="h-auto min-h-9 justify-center border py-2 shadow-none transition-colors" + :class=" + graphManagementMode === mode + ? 'border-primary/70 bg-muted/50 font-medium text-foreground ring-1 ring-primary/25' + : 'border-border bg-card text-muted-foreground hover:border-muted-foreground/30 hover:bg-muted/40 hover:text-foreground' + " + role="tab" + :aria-selected="graphManagementMode === mode" + tabindex="0" + @click="setGraphManagementMode(mode)" + @keydown="onModeSwitchKeydown($event, mode)" + > + {{ GRAPH_MANAGEMENT_MODE_LABELS[mode] }} + </Button> + <div + v-else + class="flex flex-col gap-1.5 rounded-lg border border-dashed border-rose-200/80 bg-rose-500/[0.04] px-3 py-2.5 text-left text-muted-foreground dark:border-rose-900/40 dark:bg-rose-950/20" + role="tab" + :aria-selected="false" + :aria-disabled="true" + :title="graphManagementModeLockReason(mode, graphManagementModeGate) ?? undefined" + > + <div class="flex items-center gap-2"> + <Lock class="size-3.5 shrink-0 text-rose-700/80 dark:text-rose-400/90" /> + <span class="text-sm font-medium leading-tight text-foreground/80"> + {{ GRAPH_MANAGEMENT_MODE_LABELS[mode] }} + </span> + </div> + <p class="text-[11px] leading-snug text-rose-800/90 dark:text-rose-300/90"> + {{ graphManagementModeLockReason(mode, graphManagementModeGate) }} + </p> + </div> + </template> + </div> </div> - </CardContent> + </CardHeader> </Card> <SharedConversationPanel v-model:draft-message="draftMessage" :mode-label="graphManagementModeLabel" + :description="graphManagementChatDescription" :input-placeholder="graphManagementInputPlaceholder" :session-status-label="sessionStatusLabel" :session="extractionSession" @@ -1581,52 +1636,51 @@ watch(selectedOpsDataSourceId, () => { @send-message="sendChatMessage" /> - <div class="grid gap-4 xl:grid-cols-[280px_1fr]"> - <div - class="graph-management-rail rounded border" - role="listbox" - aria-label="Graph management status and artifacts" - > - <div class="border-b px-3 py-2"> - <p class="text-xs font-medium text-muted-foreground">Status & artifacts</p> - </div> - <div class="space-y-1.5 p-2"> - <button - v-for="item in visibleRailItems" - :key="item.id" - type="button" - role="option" - :aria-selected="selectedRailItemId === item.id" - tabindex="0" - class="w-full rounded border px-2 py-2 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - :class="[ - stepStatusTintClass(item.status), - selectedRailItemId === item.id ? 'border-primary ring-1 ring-primary/30' : 'hover:bg-muted/40', - ]" - @click="selectRailItem(item.id)" - @keydown="onRailKeydown($event, item.id)" - > - <div class="flex items-center justify-between gap-2"> - <p class="font-medium">{{ item.label }}</p> - <Badge variant="outline" class="text-[10px]">{{ item.status }}</Badge> - </div> - <p class="mt-1 text-muted-foreground">{{ item.detailHint }}</p> - <p class="mt-1 text-[10px] text-muted-foreground">Updated {{ item.lastUpdated }}</p> - </button> - </div> - </div> - - <Card class="graph-management-detail"> - <CardHeader class="pb-3"> - <CardTitle class="text-base"> - {{ selectedRailItem?.label ?? 'Workspace detail' }} - </CardTitle> - <CardDescription> - Mode: - <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span> + <div class="graph-management-artifacts grid gap-6 lg:grid-cols-2 lg:items-start"> + <Card id="graph-management-schema-artifacts" class="graph-management-schema-panel scroll-mt-6"> + <CardHeader class="pb-2"> + <CardTitle class="text-sm font-semibold">Schema & artifacts</CardTitle> + <CardDescription class="text-xs"> + Workspace signals for + <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span>. + <template v-if="schemaRailItems.length > 1"> + Select an artifact to inspect its detail below. + </template> </CardDescription> </CardHeader> - <CardContent class="space-y-4 text-sm"> + <CardContent class="space-y-4 p-3 pt-0 text-sm"> + <div v-if="schemaRailItems.length > 1" class="space-y-1.5"> + <button + v-for="item in schemaRailItems" + :key="item.id" + type="button" + :class="graphManagementArtifactRowClass( + selectedRailItemId === item.id, + graphManagementRailItemDone(item.status), + )" + @click="selectSchemaRailItem(item.id)" + @keydown="onSchemaRailKeydown($event, item.id)" + > + <span class="font-medium leading-tight">{{ item.label }}</span> + <span class="text-xs text-muted-foreground">{{ graphManagementArtifactHint(item) }}</span> + </button> + </div> + <p + v-else-if="schemaRailItems.length === 0" + class="rounded-lg border border-dashed p-3 text-xs text-muted-foreground" + > + No schema artifacts for this mode. + </p> + + <div class="graph-management-detail space-y-4 border-t pt-4"> + <div> + <p class="text-sm font-semibold"> + {{ selectedSchemaRailItem?.label ?? 'Schema & artifacts' }} + </p> + <p class="text-xs text-muted-foreground"> + Mode: {{ graphManagementModeLabel }} + </p> + </div> <template v-if="selectedRailItemId === 'schema-readiness'"> <div class="rounded border p-3"> <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> @@ -1720,99 +1774,7 @@ watch(selectedOpsDataSourceId, () => { </div> </template> - <template v-else-if="selectedRailItemId === 'session-pointers'"> - <div class="grid gap-2 md:grid-cols-3 text-xs"> - <div class="rounded border px-3 py-2"> - <p class="text-muted-foreground">Active schema bootstrap session</p> - <p class="mt-1 break-all font-mono"> - {{ statusProjection.session_pointers.active_schema_bootstrap_session_id ?? 'None' }} - </p> - </div> - <div class="rounded border px-3 py-2"> - <p class="text-muted-foreground">Active extraction operations session</p> - <p class="mt-1 break-all font-mono"> - {{ statusProjection.session_pointers.active_extraction_operations_session_id ?? 'None' }} - </p> - </div> - <div class="rounded border px-3 py-2"> - <p class="text-muted-foreground">Most recent completed session</p> - <p class="mt-1 break-all font-mono"> - {{ statusProjection.session_pointers.most_recent_completed_session_id ?? 'None' }} - </p> - </div> - </div> - <div class="space-y-3 border-t pt-3"> - <div class="flex items-center justify-between"> - <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Session History - </p> - <Button - size="sm" - variant="ghost" - class="h-6 px-2 text-[10px]" - :disabled="sessionHistoryLoading" - @click="loadSessionHistory" - > - Refresh - </Button> - </div> - <div - v-if="sessionHistoryLoading" - class="flex items-center gap-2 text-xs text-muted-foreground" - > - <Loader2 class="size-3.5 animate-spin" /> - Loading session history... - </div> - <div - v-else-if="sessionHistory.length === 0" - class="rounded border border-dashed px-3 py-4 text-xs text-muted-foreground" - > - No archived or active sessions found for this scope yet. - </div> - <div v-else class="space-y-2"> - <div - v-for="entry in sessionHistory" - :key="entry.id" - class="rounded border px-3 py-2 text-xs" - > - <div class="flex flex-wrap items-center justify-between gap-2"> - <p class="font-mono break-all">{{ entry.id }}</p> - <Badge :variant="entry.is_active ? 'default' : 'secondary'"> - {{ entry.is_active ? 'Active' : 'Archived' }} - </Badge> - </div> - <p class="mt-1 text-muted-foreground"> - Updated {{ new Date(entry.updated_at).toLocaleString() }} - <span v-if="entry.archived_at"> - · Archived {{ new Date(entry.archived_at).toLocaleString() }} - </span> - </p> - <p class="mt-1 text-muted-foreground"> - {{ entry.message_count }} message(s) - · {{ entry.run_metrics.length }} linked run(s) - </p> - <div - v-if="entry.run_metrics.length > 0" - class="mt-2 space-y-1.5 rounded border bg-muted/20 p-2" - > - <div - v-for="metric in entry.run_metrics" - :key="metric.sync_run_id" - class="flex flex-wrap items-center justify-between gap-2" - > - <span class="font-mono">{{ metric.mutation_log_id ?? metric.sync_run_id }}</span> - <span class="text-muted-foreground"> - {{ metric.token_usage_total ?? 0 }} tokens · - ${{ (metric.cost_total_usd ?? 0).toFixed(2) }} - </span> - </div> - </div> - </div> - </div> - </div> - </template> - - <template v-else-if="graphManagementMode === 'extraction-jobs'"> + <template v-else-if="selectedRailItemId === 'extraction-jobs-setup'"> <p class="text-muted-foreground"> Trigger extraction jobs, inspect run history, and view run logs without leaving this workspace. </p> @@ -1924,7 +1886,7 @@ watch(selectedOpsDataSourceId, () => { </div> </template> - <template v-else-if="graphManagementMode === 'one-off-mutations'"> + <template v-else-if="selectedRailItemId === 'mutation-authoring'"> <p class="text-muted-foreground"> Author and apply one-off JSONL mutations directly in this workspace. </p> @@ -1952,11 +1914,112 @@ watch(selectedOpsDataSourceId, () => { <template v-else> <p class="text-xs text-muted-foreground"> - Select a status or artifact item to inspect mode-specific workspace content. + Select a schema artifact to inspect mode-specific workspace content. </p> </template> + </div> </CardContent> </Card> + + <Card id="graph-management-session-pointers" class="graph-management-session-pointers scroll-mt-6 lg:sticky lg:top-4 lg:self-start"> + <CardHeader class="pb-3"> + <CardTitle class="text-base">Session pointers</CardTitle> + <CardDescription> + Active bootstrap and extraction sessions, plus archived history for this knowledge graph. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4 text-sm"> + <div class="grid gap-2 md:grid-cols-3 text-xs"> + <div class="rounded-lg border px-3 py-2"> + <p class="text-muted-foreground">Active schema bootstrap session</p> + <p class="mt-1 break-all font-mono"> + {{ statusProjection.session_pointers.active_schema_bootstrap_session_id ?? 'None' }} + </p> + </div> + <div class="rounded-lg border px-3 py-2"> + <p class="text-muted-foreground">Active extraction operations session</p> + <p class="mt-1 break-all font-mono"> + {{ statusProjection.session_pointers.active_extraction_operations_session_id ?? 'None' }} + </p> + </div> + <div class="rounded-lg border px-3 py-2"> + <p class="text-muted-foreground">Most recent completed session</p> + <p class="mt-1 break-all font-mono"> + {{ statusProjection.session_pointers.most_recent_completed_session_id ?? 'None' }} + </p> + </div> + </div> + <div class="space-y-3 border-t pt-3"> + <div class="flex items-center justify-between"> + <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> + Session History + </p> + <Button + size="sm" + variant="ghost" + class="h-6 px-2 text-[10px]" + :disabled="sessionHistoryLoading" + @click="loadSessionHistory" + > + Refresh + </Button> + </div> + <div + v-if="sessionHistoryLoading" + class="flex items-center gap-2 text-xs text-muted-foreground" + > + <Loader2 class="size-3.5 animate-spin" /> + Loading session history... + </div> + <div + v-else-if="sessionHistory.length === 0" + class="rounded-lg border border-dashed px-3 py-4 text-xs text-muted-foreground" + > + No archived or active sessions found for this scope yet. + </div> + <div v-else class="space-y-2"> + <div + v-for="entry in sessionHistory" + :key="entry.id" + class="rounded-lg border px-3 py-2 text-xs" + > + <div class="flex flex-wrap items-center justify-between gap-2"> + <p class="font-mono break-all">{{ entry.id }}</p> + <Badge :variant="entry.is_active ? 'default' : 'secondary'"> + {{ entry.is_active ? 'Active' : 'Archived' }} + </Badge> + </div> + <p class="mt-1 text-muted-foreground"> + Updated {{ new Date(entry.updated_at).toLocaleString() }} + <span v-if="entry.archived_at"> + · Archived {{ new Date(entry.archived_at).toLocaleString() }} + </span> + </p> + <p class="mt-1 text-muted-foreground"> + {{ entry.message_count }} message(s) + · {{ entry.run_metrics.length }} linked run(s) + </p> + <div + v-if="entry.run_metrics.length > 0" + class="mt-2 space-y-1.5 rounded-lg border bg-muted/20 p-2" + > + <div + v-for="metric in entry.run_metrics" + :key="metric.sync_run_id" + class="flex flex-wrap items-center justify-between gap-2" + > + <span class="font-mono">{{ metric.mutation_log_id ?? metric.sync_run_id }}</span> + <span class="text-muted-foreground"> + {{ metric.token_usage_total ?? 0 }} tokens · + ${{ (metric.cost_total_usd ?? 0).toFixed(2) }} + </span> + </div> + </div> + </div> + </div> + </div> + </CardContent> + </Card> </div> </section> </template> diff --git a/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts b/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts new file mode 100644 index 000000000..c250bd2bb --- /dev/null +++ b/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + filterSchemaRailItems, + graphManagementArtifactRowClass, + graphManagementRailItemDone, + resolveSchemaRailSelection, +} from '../utils/kgGraphManagementArtifacts' +import { buildGraphManagementRailItems } from '../utils/kgGraphManagement' + +describe('kgGraphManagementArtifacts', () => { + const items = buildGraphManagementRailItems({ + workspaceMode: 'schema_bootstrap', + transitionEligible: false, + blockingReasonCount: 1, + prepopulatedGapCount: 0, + sessionUpdatedAt: '2026-01-01', + hasActiveSession: true, + }) + + it('excludes session pointers from schema artifact navigation', () => { + const schemaItems = filterSchemaRailItems(items) + expect(schemaItems.map((item) => item.id)).not.toContain('session-pointers') + expect(schemaItems.length).toBeGreaterThan(0) + }) + + it('resolves schema selection for the active mode', () => { + expect( + resolveSchemaRailSelection(null, 'initial-schema-design', items), + ).toBe('schema-readiness') + expect( + resolveSchemaRailSelection('session-pointers', 'extraction-jobs', items), + ).toBe('extraction-jobs-setup') + }) + + it('maps ready status to done artifact rows', () => { + expect(graphManagementRailItemDone('ready')).toBe(true) + expect(graphManagementArtifactRowClass(true, true)).toContain('ring-primary') + expect(graphManagementArtifactRowClass(false, true)).toContain('green') + }) +}) diff --git a/src/dev-ui/app/tests/kg-graph-management-modes.test.ts b/src/dev-ui/app/tests/kg-graph-management-modes.test.ts new file mode 100644 index 000000000..511e851c7 --- /dev/null +++ b/src/dev-ui/app/tests/kg-graph-management-modes.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + graphManagementModeLockReason, + isGraphManagementModeUnlocked, + resolveEffectiveGraphManagementMode, +} from '../utils/kgGraphManagement' + +describe('graph management mode gates', () => { + const bootstrap = { + workspaceMode: 'schema_bootstrap' as const, + transitionEligible: false, + } + + const validatedBootstrap = { + workspaceMode: 'schema_bootstrap' as const, + transitionEligible: true, + } + + const operations = { + workspaceMode: 'extraction_operations' as const, + transitionEligible: true, + } + + it('always unlocks initial schema design', () => { + expect(isGraphManagementModeUnlocked('initial-schema-design', bootstrap)).toBe(true) + }) + + it('locks extraction modes until extraction operations', () => { + expect(isGraphManagementModeUnlocked('extraction-jobs', bootstrap)).toBe(false) + expect(isGraphManagementModeUnlocked('one-off-mutations', validatedBootstrap)).toBe(false) + expect(isGraphManagementModeUnlocked('extraction-jobs', operations)).toBe(true) + expect(isGraphManagementModeUnlocked('one-off-mutations', operations)).toBe(true) + }) + + it('returns contextual lock reasons', () => { + expect(graphManagementModeLockReason('extraction-jobs', bootstrap)).toContain('validation') + expect(graphManagementModeLockReason('one-off-mutations', validatedBootstrap)).toContain( + 'Extraction/Mutations', + ) + expect(graphManagementModeLockReason('extraction-jobs', operations)).toBeNull() + }) + + it('coerces locked query modes back to initial schema design', () => { + expect(resolveEffectiveGraphManagementMode('extraction-jobs', bootstrap)).toBe('initial-schema-design') + expect(resolveEffectiveGraphManagementMode('extraction-jobs', operations)).toBe('extraction-jobs') + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 77b270354..2b0d67374 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -354,9 +354,10 @@ describe('KG-MANAGE-015 - graph-scoped maintain step and round trip', () => { }) describe('Shared conversation panel - extraction UX contract', () => { - it('renders resume-session action and explicit server-side persistence note', () => { + it('renders phase-2 style conversational intelligence header and resume action', () => { + expect(sharedConversationPanelVue).toContain('Graph Management Assistant') expect(sharedConversationPanelVue).toContain('Resume session') - expect(sharedConversationPanelVue).toContain('No local cache: conversation state is server-side only.') + expect(sharedConversationPanelVue).toContain('Sparkles') }) it('renders clear-chat confirmation dialog before emitting clear action', () => { @@ -365,15 +366,18 @@ describe('Shared conversation panel - extraction UX contract', () => { expect(sharedConversationPanelVue).toContain("emit('clearChat')") }) - it('renders activity/thinking timeline lines and auto-scrolls timeline updates', () => { - expect(sharedConversationPanelVue).toContain('activityTimeline') - expect(sharedConversationPanelVue).toContain('timelineRef') - expect(sharedConversationPanelVue).toContain('scrollTop = timelineRef.value.scrollHeight') + it('renders bubble chat, thinking state, and auto-scroll', () => { + expect(sharedConversationPanelVue).toContain('thinkingDisplaySlots') + expect(sharedConversationPanelVue).toContain('chatScrollRef') + expect(sharedConversationPanelVue).toContain('renderAssistantHtml') + expect(sharedConversationPanelVue).toContain('scrollToBottom') + expect(sharedConversationPanelVue).toContain('el.scrollTop = el.scrollHeight') }) it('accepts mode-aware input placeholder and session status props', () => { expect(sharedConversationPanelVue).toContain('inputPlaceholder') expect(sharedConversationPanelVue).toContain('sessionStatusLabel') + expect(sharedConversationPanelVue).toContain('footerHint') }) }) @@ -399,6 +403,8 @@ describe('KG-MANAGE-007 - graph management modes', () => { } expect(manageWorkspaceVue).toContain('graphManagementMode') expect(manageWorkspaceVue).toContain('parseGraphManagementModeQuery') + expect(manageWorkspaceVue).toContain('isGraphManagementModeUnlocked') + expect(manageWorkspaceVue).toContain('graphManagementModeLockReason') }) it('defaults mode from workspace lifecycle state', () => { @@ -413,11 +419,12 @@ describe('KG-MANAGE-007 - graph management modes', () => { }) describe('KG-MANAGE-008 - hybrid lower panel shared rail', () => { - it('renders persistent status and artifact rail with keyboard selection', () => { - expect(manageWorkspaceVue).toContain('graph-management-rail') - expect(manageWorkspaceVue).toContain('buildGraphManagementRailItems') - expect(manageWorkspaceVue).toContain('role="listbox"') - expect(manageWorkspaceVue).toContain('@keydown') + it('renders side-by-side schema artifacts and session pointers panels', () => { + expect(manageWorkspaceVue).toContain('graph-management-artifacts') + expect(manageWorkspaceVue).toContain('Schema & artifacts') + expect(manageWorkspaceVue).toContain('graph-management-session-pointers') + expect(manageWorkspaceVue).toContain('graphManagementArtifactRowClass') + expect(manageWorkspaceVue).toContain('schemaRailItems') }) it('builds rail items with status and last-updated metadata', () => { @@ -442,8 +449,8 @@ describe('KG-MANAGE-009 - hybrid lower panel mode-specific detail', () => { expect(manageWorkspaceVue).toContain('graph-management-detail') expect(manageWorkspaceVue).toContain('selectedRailItemId') expect(manageWorkspaceVue).toContain("selectedRailItemId === 'schema-readiness'") - expect(manageWorkspaceVue).toContain("graphManagementMode === 'extraction-jobs'") - expect(manageWorkspaceVue).toContain("graphManagementMode === 'one-off-mutations'") + expect(manageWorkspaceVue).toContain("selectedRailItemId === 'extraction-jobs-setup'") + expect(manageWorkspaceVue).toContain("selectedRailItemId === 'mutation-authoring'") }) it('filters rail items to the active mode', () => { @@ -541,9 +548,9 @@ describe('KG-MANAGE-016 - graph management top controls', () => { describe('KG-MANAGE-017 - chat input keyboard contract', () => { it('wires Enter-to-send and Shift+Enter newline handling in shared conversation panel', () => { - expect(sharedConversationPanelVue).toContain('handleChatInputKeydown') - expect(sharedConversationPanelVue).toContain('@keydown="onChatInputKeydown"') - expect(sharedConversationPanelVue).toContain('Shift+Enter adds a new line') + expect(sharedConversationPanelVue).toContain('handleComposerEnter') + expect(sharedConversationPanelVue).toContain('@keydown.enter="handleComposerEnter"') + expect(sharedConversationPanelVue).toContain('Shift+Enter for a new line') expect(sharedConversationPanelVue).toContain("emit('sendMessage'") expect(manageWorkspaceVue).toContain('@send-message="sendChatMessage"') }) @@ -556,11 +563,9 @@ describe('KG-MANAGE-018 - keyboard operable step and rail actions', () => { expect(manageWorkspaceVue).toContain('focus-visible:ring-2 focus-visible:ring-ring') }) - it('supports keyboard activation for graph management rail selection', () => { - expect(manageWorkspaceVue).toContain('onRailKeydown') - expect(manageWorkspaceVue).toContain('role="listbox"') - expect(manageWorkspaceVue).toContain('tabindex="0"') - expect(manageWorkspaceVue).toContain('@keydown="onRailKeydown($event, item.id)"') + it('supports keyboard activation for schema artifact navigation', () => { + expect(manageWorkspaceVue).toContain('onSchemaRailKeydown') + expect(manageWorkspaceVue).toContain('@keydown="onSchemaRailKeydown($event, item.id)"') }) it('exposes keyboard-reachable graph management mode switch tabs', () => { diff --git a/src/dev-ui/app/utils/kgGraphManagement.ts b/src/dev-ui/app/utils/kgGraphManagement.ts index 203c6ce7b..9ddecfa71 100644 --- a/src/dev-ui/app/utils/kgGraphManagement.ts +++ b/src/dev-ui/app/utils/kgGraphManagement.ts @@ -165,3 +165,37 @@ export function buildGraphManagementStepUrl( ): string { return `/knowledge-graphs/${encodeURIComponent(kgId)}/manage?step=graph-management&gm_mode=${mode}` } + +export interface GraphManagementModeGateInput { + workspaceMode: 'schema_bootstrap' | 'extraction_operations' + transitionEligible: boolean +} + +export function isGraphManagementModeUnlocked( + mode: GraphManagementMode, + input: GraphManagementModeGateInput, +): boolean { + if (mode === 'initial-schema-design') return true + return input.workspaceMode === 'extraction_operations' +} + +export function graphManagementModeLockReason( + mode: GraphManagementMode, + input: GraphManagementModeGateInput, +): string | null { + if (isGraphManagementModeUnlocked(mode, input)) return null + if (input.transitionEligible) { + return 'Schema validated — use Go to Extraction/Mutations in Schema readiness to unlock.' + } + return 'Complete schema design and pass validation to unlock.' +} + +export function resolveEffectiveGraphManagementMode( + requested: GraphManagementMode | null, + input: GraphManagementModeGateInput, +): GraphManagementMode { + const fallback = resolveDefaultGraphManagementMode(input.workspaceMode) + if (!requested) return fallback + if (isGraphManagementModeUnlocked(requested, input)) return requested + return 'initial-schema-design' +} diff --git a/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts b/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts new file mode 100644 index 000000000..a93b42ad0 --- /dev/null +++ b/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts @@ -0,0 +1,55 @@ +import { cn } from '@/lib/utils' +import { + filterRailItemsForMode, + type GraphManagementMode, + type GraphManagementRailItem, + type GraphManagementRailItemId, +} from './kgGraphManagement' +import type { StepStatusLabel } from './kgManageWorkspace' + +export function filterSchemaRailItems(items: GraphManagementRailItem[]): GraphManagementRailItem[] { + return items.filter((item) => item.id !== 'session-pointers') +} + +export function resolveSchemaRailSelection( + selectedId: GraphManagementRailItemId | null, + mode: GraphManagementMode, + items: GraphManagementRailItem[], +): GraphManagementRailItemId | null { + const schemaItems = filterSchemaRailItems(filterRailItemsForMode(items, mode)) + if (schemaItems.length === 0) return null + if (selectedId && schemaItems.some((item) => item.id === selectedId)) { + return selectedId + } + return schemaItems[0]?.id ?? null +} + +export function graphManagementRailItemDone(status: StepStatusLabel): boolean { + return status === 'ready' +} + +export function graphManagementArtifactRowClass(selected: boolean, done: boolean): string { + return cn( + 'flex w-full flex-col gap-0.5 rounded-lg border p-2.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + done + ? 'border-green-500/35 bg-green-500/5 dark:border-green-500/25 dark:bg-green-950/15' + : 'border-border bg-card hover:bg-muted/50', + selected && 'ring-2 ring-primary/30', + ) +} + +export function graphManagementArtifactHint(item: GraphManagementRailItem): string { + if (item.id === 'schema-readiness') { + return item.status === 'ready' ? 'Ready to transition' : 'Bootstrap checklist' + } + if (item.id === 'validation-diagnostics') { + return item.status === 'ready' ? 'No blocking issues' : 'Review diagnostics' + } + if (item.id === 'extraction-jobs-setup') { + return item.status === 'ready' ? 'Operations mode' : 'Complete schema first' + } + if (item.id === 'mutation-authoring') { + return item.status === 'ready' ? 'JSONL mutations' : 'Complete schema first' + } + return item.detailHint +} From 95cb9ad47174c7cb95e8792348a4151e3cf218cf Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Thu, 28 May 2026 22:40:58 -0400 Subject: [PATCH 61/80] feat(extraction): add streaming chat turn service and NDJSON endpoint (#744) Introduce sticky-session-aware chat orchestration with JobPackage gating, UI-mode skill overlays, and a tracer-bullet deterministic agent. Closes #739. Closes #740. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/extraction/application/__init__.py | 10 +- .../application/agent_session_service.py | 16 ++ .../application/chat_turn_service.py | 158 ++++++++++++++++++ .../application/job_package_gate.py | 53 ++++++ .../application/skill_resolution_service.py | 45 ++++- src/api/extraction/dependencies.py | 32 ++++ src/api/extraction/domain/value_objects.py | 24 +++ .../deterministic_chat_agent.py | 46 +++++ .../ingestion_readiness_reader.py | 36 ++++ src/api/extraction/ports/chat_agent.py | 23 +++ .../extraction/ports/ingestion_readiness.py | 17 ++ src/api/extraction/presentation/models.py | 8 + src/api/extraction/presentation/routes.py | 39 ++++- .../application/test_chat_turn_service.py | 151 +++++++++++++++++ .../application/test_job_package_gate.py | 38 +++++ 15 files changed, 692 insertions(+), 4 deletions(-) create mode 100644 src/api/extraction/application/chat_turn_service.py create mode 100644 src/api/extraction/application/job_package_gate.py create mode 100644 src/api/extraction/infrastructure/deterministic_chat_agent.py create mode 100644 src/api/extraction/infrastructure/ingestion_readiness_reader.py create mode 100644 src/api/extraction/ports/chat_agent.py create mode 100644 src/api/extraction/ports/ingestion_readiness.py create mode 100644 src/api/tests/unit/extraction/application/test_chat_turn_service.py create mode 100644 src/api/tests/unit/extraction/application/test_job_package_gate.py diff --git a/src/api/extraction/application/__init__.py b/src/api/extraction/application/__init__.py index fd5d9c04c..407be92a9 100644 --- a/src/api/extraction/application/__init__.py +++ b/src/api/extraction/application/__init__.py @@ -5,9 +5,15 @@ """ from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.chat_turn_service import ExtractionChatTurnService from extraction.application.skill_resolution_service import ( ExtractionSkillResolutionService, + ResolvedExtractionSkillPack, ) -__all__ = ["ExtractionAgentSessionService", "ExtractionSkillResolutionService"] - +__all__ = [ + "ExtractionAgentSessionService", + "ExtractionChatTurnService", + "ExtractionSkillResolutionService", + "ResolvedExtractionSkillPack", +] diff --git a/src/api/extraction/application/agent_session_service.py b/src/api/extraction/application/agent_session_service.py index fdda14574..d22f47a5d 100644 --- a/src/api/extraction/application/agent_session_service.py +++ b/src/api/extraction/application/agent_session_service.py @@ -17,6 +17,7 @@ IExtractionAgentSessionRepository, IExtractionSessionRunMetricsReader, ) +from extraction.ports.runtime import IStickySessionRuntimeManager @dataclass(frozen=True) @@ -35,10 +36,12 @@ def __init__( repository: IExtractionAgentSessionRepository, skill_resolution_service: ExtractionSkillResolutionService | None = None, run_metrics_reader: IExtractionSessionRunMetricsReader | None = None, + sticky_runtime_manager: IStickySessionRuntimeManager | None = None, ) -> None: self._repository = repository self._skill_resolution_service = skill_resolution_service self._run_metrics_reader = run_metrics_reader + self._sticky_runtime_manager = sticky_runtime_manager @staticmethod def _build_bootstrap_intake_prompt() -> str: @@ -96,6 +99,12 @@ async def get_or_create_active_session( await self._repository.save(session) return session + async def save_session(self, session: ExtractionAgentSession) -> ExtractionAgentSession: + """Persist session mutations after a chat turn.""" + session.updated_at = datetime.now(UTC) + await self._repository.save(session) + return session + async def clear_chat( self, user_id: str, @@ -108,6 +117,13 @@ async def clear_chat( mode=mode, ) if active is not None: + if self._sticky_runtime_manager is not None: + self._sticky_runtime_manager.reset_runtime( + session_id=active.id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + ) active.archive() await self._repository.save(active) diff --git a/src/api/extraction/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py new file mode 100644 index 000000000..64081bf9d --- /dev/null +++ b/src/api/extraction/application/chat_turn_service.py @@ -0,0 +1,158 @@ +"""Orchestrates graph-management chat turns with sticky runtime and streaming events.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.job_package_gate import resolve_job_package_gate +from extraction.application.skill_resolution_service import ExtractionSkillResolutionService +from extraction.domain.value_objects import ( + ExtractionSessionMode, + GraphManagementUiMode, + SessionJobPackagePhase, +) +from extraction.ports.chat_agent import IExtractionChatAgent +from extraction.ports.ingestion_readiness import IIngestionReadinessReader +from extraction.ports.runtime import IStickySessionRuntimeManager + + +class ExtractionChatTurnService: + """Coordinates sticky runtime, JobPackage gating, and agent execution.""" + + def __init__( + self, + *, + session_service: ExtractionAgentSessionService, + skill_resolution_service: ExtractionSkillResolutionService, + ingestion_readiness_reader: IIngestionReadinessReader, + sticky_runtime_manager: IStickySessionRuntimeManager, + chat_agent: IExtractionChatAgent, + ) -> None: + self._session_service = session_service + self._skill_resolution_service = skill_resolution_service + self._ingestion_readiness_reader = ingestion_readiness_reader + self._sticky_runtime_manager = sticky_runtime_manager + self._chat_agent = chat_agent + + async def stream_chat_turn( + self, + *, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + message: str, + ) -> AsyncIterator[dict[str, Any]]: + trimmed = message.strip() + if not trimmed: + yield { + "type": "done", + "ok": False, + "error": { + "code": "EMPTY_MESSAGE", + "message": "Message must not be empty.", + }, + } + return + + session = await self._session_service.get_or_create_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + + resolved_skills = await self._skill_resolution_service.resolve_for_graph_management_turn( + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + ) + session.runtime_context["agent_configuration"] = { + "system_prompt": resolved_skills.system_prompt, + "prompt_hierarchy": list(resolved_skills.prompt_hierarchy), + "guardrails": list(resolved_skills.guardrails), + "skills": dict(resolved_skills.skills), + "graph_management_ui_mode": ui_mode.value, + } + + lease = self._sticky_runtime_manager.get_or_start_runtime( + session_id=session.id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + ) + session.runtime_context["sticky_runtime"] = { + "container_id": lease.container_id, + "status": lease.status, + "expires_at": lease.expires_at.isoformat(), + } + + yield { + "type": "thinking", + "recent": [ + "Contacting Graph Management Assistant…", + f"Sticky container {lease.container_id[:8]} active", + ], + } + + readiness = await self._ingestion_readiness_reader.read_for_knowledge_graph( + knowledge_graph_id=knowledge_graph_id, + ) + gate = resolve_job_package_gate(ui_mode=ui_mode, readiness=readiness) + session.runtime_context["job_package"] = { + "phase": gate.phase.value, + "data_source_count": readiness.data_source_count, + "prepared_source_count": readiness.prepared_source_count, + } + + session.message_history.append({"role": "user", "content": trimmed}) + session.updated_at = datetime.now(UTC) + + if gate.phase == SessionJobPackagePhase.AWAITING_PREPARE: + wait_message = gate.wait_message or "Waiting for JobPackage ingestion context." + session.runtime_context["activity_lines"] = [wait_message] + yield { + "type": "wait", + "phase": gate.phase.value, + "message": wait_message, + } + yield { + "type": "thinking", + "recent": ["Waiting for JobPackage ingestion context…", wait_message], + } + assistant_reply = ( + f"**Waiting for ingestion context**\n\n{wait_message}\n\n" + "I'll respond with full repository-aware guidance once JobPackage " + "material is prepared for this knowledge graph." + ) + session.message_history.append({"role": "assistant", "content": assistant_reply}) + session.updated_at = datetime.now(UTC) + await self._session_service.save_session(session) + yield {"type": "done", "ok": True, "reply": assistant_reply, "wait": True} + return + + session.runtime_context["job_package"]["phase"] = SessionJobPackagePhase.READY.value + thinking_lines: list[str] = [] + assistant_reply: str | None = None + async for event in self._chat_agent.stream_turn( + session=session, + user_message=trimmed, + ui_mode=ui_mode, + ): + if event.get("type") == "thinking": + recent = event.get("recent") + if isinstance(recent, list): + thinking_lines = [str(line) for line in recent if str(line).strip()] + session.runtime_context["activity_lines"] = thinking_lines + if event.get("type") == "done": + if event.get("ok") is True and event.get("reply"): + assistant_reply = str(event["reply"]) + yield event + + if assistant_reply: + session.message_history.append({"role": "assistant", "content": assistant_reply}) + session.updated_at = datetime.now(UTC) + session.runtime_context.pop("activity_lines", None) + await self._session_service.save_session(session) diff --git a/src/api/extraction/application/job_package_gate.py b/src/api/extraction/application/job_package_gate.py new file mode 100644 index 000000000..4f88b33ff --- /dev/null +++ b/src/api/extraction/application/job_package_gate.py @@ -0,0 +1,53 @@ +"""Pure helpers for JobPackage readiness gating in chat turns.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from extraction.domain.value_objects import ( + GraphManagementUiMode, + IngestionReadinessSnapshot, + SessionJobPackagePhase, +) + + +@dataclass(frozen=True) +class JobPackageGateDecision: + """Resolved JobPackage gate for one chat turn.""" + + phase: SessionJobPackagePhase + wait_message: str | None = None + + +def resolve_job_package_gate( + *, + ui_mode: GraphManagementUiMode, + readiness: IngestionReadinessSnapshot, +) -> JobPackageGateDecision: + """Return whether a chat turn must wait for JobPackage context.""" + if ui_mode in { + GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + GraphManagementUiMode.ONE_OFF_MUTATIONS, + }: + return JobPackageGateDecision(phase=SessionJobPackagePhase.NOT_REQUIRED) + + if readiness.data_source_count == 0: + return JobPackageGateDecision( + phase=SessionJobPackagePhase.AWAITING_PREPARE, + wait_message=( + "Waiting for a connected data source. Add and prepare data sources " + "under Data sources before extraction job chat can run." + ), + ) + + if readiness.prepared_source_count < readiness.data_source_count: + return JobPackageGateDecision( + phase=SessionJobPackagePhase.AWAITING_PREPARE, + wait_message=( + "Waiting for JobPackage ingestion context. Prepare all data sources " + f"({readiness.prepared_source_count}/{readiness.data_source_count} ready) " + "so the sticky session container can load repository files." + ), + ) + + return JobPackageGateDecision(phase=SessionJobPackagePhase.READY) diff --git a/src/api/extraction/application/skill_resolution_service.py b/src/api/extraction/application/skill_resolution_service.py index 0cf5f137f..11e420157 100644 --- a/src/api/extraction/application/skill_resolution_service.py +++ b/src/api/extraction/application/skill_resolution_service.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from extraction.domain.value_objects import ExtractionSessionMode +from extraction.domain.value_objects import ExtractionSessionMode, GraphManagementUiMode from extraction.ports.repositories import IExtractionSkillOverrideRepository @@ -87,6 +87,27 @@ class ResolvedExtractionSkillPack: } +_UI_MODE_SKILL_OVERLAYS: dict[GraphManagementUiMode, dict[str, str]] = { + GraphManagementUiMode.INITIAL_SCHEMA_DESIGN: { + "ui_mode_framing": ( + "Focus on schema bootstrap: entity/relationship modeling, intake, and " + "prepopulation guidance before extraction jobs." + ), + }, + GraphManagementUiMode.EXTRACTION_JOBS: { + "ui_mode_framing": ( + "Focus on extraction job setup, JobPackage-aware file targeting, and " + "incremental sync planning." + ), + }, + GraphManagementUiMode.ONE_OFF_MUTATIONS: { + "ui_mode_framing": ( + "Focus on scoped one-off graph mutations with mutation-log auditability." + ), + }, +} + + class ExtractionSkillResolutionService: """Resolve session skills from global templates + KG overrides.""" @@ -123,3 +144,25 @@ async def resolve_for_session( skills=resolved, ) + async def resolve_for_graph_management_turn( + self, + *, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + ) -> ResolvedExtractionSkillPack: + """Resolve base session skills plus graph-management UI mode overlay.""" + base = await self.resolve_for_session( + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + overlay = dict(_UI_MODE_SKILL_OVERLAYS.get(ui_mode, {})) + merged_skills = dict(base.skills) + merged_skills.update(overlay) + return ResolvedExtractionSkillPack( + system_prompt=base.system_prompt, + prompt_hierarchy=base.prompt_hierarchy, + guardrails=base.guardrails, + skills=merged_skills, + ) + diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index a4771b42c..b2aaf1a09 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -8,8 +8,11 @@ from extraction.application import ( ExtractionAgentSessionService, + ExtractionChatTurnService, ExtractionSkillResolutionService, ) +from extraction.infrastructure.deterministic_chat_agent import DeterministicExtractionChatAgent +from extraction.infrastructure.ingestion_readiness_reader import SqlIngestionReadinessReader from extraction.infrastructure.repositories import ( ExtractionAgentSessionRepository, ExtractionSessionRunMetricsReader, @@ -40,6 +43,9 @@ def get_ephemeral_extraction_worker_launcher() -> IEphemeralExtractionWorkerLaun def get_extraction_agent_session_service( session: Annotated[AsyncSession, Depends(get_write_session)], + sticky_runtime_manager: Annotated[ + IStickySessionRuntimeManager, Depends(get_sticky_session_runtime_manager) + ], ) -> ExtractionAgentSessionService: """Get ExtractionAgentSessionService instance.""" skill_resolution_service = ExtractionSkillResolutionService( @@ -49,4 +55,30 @@ def get_extraction_agent_session_service( repository=ExtractionAgentSessionRepository(session=session), skill_resolution_service=skill_resolution_service, run_metrics_reader=ExtractionSessionRunMetricsReader(session=session), + sticky_runtime_manager=sticky_runtime_manager, + ) + + +def get_extraction_chat_turn_service( + session: Annotated[AsyncSession, Depends(get_write_session)], + sticky_runtime_manager: Annotated[ + IStickySessionRuntimeManager, Depends(get_sticky_session_runtime_manager) + ], +) -> ExtractionChatTurnService: + """Get ExtractionChatTurnService instance.""" + skill_resolution_service = ExtractionSkillResolutionService( + override_repository=ExtractionSkillOverrideRepository() + ) + session_service = ExtractionAgentSessionService( + repository=ExtractionAgentSessionRepository(session=session), + skill_resolution_service=skill_resolution_service, + run_metrics_reader=ExtractionSessionRunMetricsReader(session=session), + sticky_runtime_manager=sticky_runtime_manager, + ) + return ExtractionChatTurnService( + session_service=session_service, + skill_resolution_service=skill_resolution_service, + ingestion_readiness_reader=SqlIngestionReadinessReader(session=session), + sticky_runtime_manager=sticky_runtime_manager, + chat_agent=DeterministicExtractionChatAgent(), ) diff --git a/src/api/extraction/domain/value_objects.py b/src/api/extraction/domain/value_objects.py index 906c77c22..cf498a8d7 100644 --- a/src/api/extraction/domain/value_objects.py +++ b/src/api/extraction/domain/value_objects.py @@ -21,6 +21,30 @@ class BootstrapIntakePath(StrEnum): GUIDED_CO_DESIGN = "guided_co_design" +class GraphManagementUiMode(StrEnum): + """Graph-management UI mode overlay for chat skill framing.""" + + INITIAL_SCHEMA_DESIGN = "initial-schema-design" + EXTRACTION_JOBS = "extraction-jobs" + ONE_OFF_MUTATIONS = "one-off-mutations" + + +class SessionJobPackagePhase(StrEnum): + """JobPackage readiness phase for sticky session chat turns.""" + + NOT_REQUIRED = "not_required" + AWAITING_PREPARE = "awaiting_job_package" + READY = "ready" + + +@dataclass(frozen=True) +class IngestionReadinessSnapshot: + """Read-only ingestion prepare counts for a knowledge graph.""" + + data_source_count: int + prepared_source_count: int + + @dataclass(frozen=True) class ExtractionSessionRunMetric: """Run-level metrics linked to an extraction session.""" diff --git a/src/api/extraction/infrastructure/deterministic_chat_agent.py b/src/api/extraction/infrastructure/deterministic_chat_agent.py new file mode 100644 index 000000000..d1ebbd7eb --- /dev/null +++ b/src/api/extraction/infrastructure/deterministic_chat_agent.py @@ -0,0 +1,46 @@ +"""Deterministic chat agent for tracer-bullet chat turn execution.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import GraphManagementUiMode + + +class DeterministicExtractionChatAgent: + """Tracer-bullet agent that simulates thinking lines and a structured reply.""" + + async def stream_turn( + self, + *, + session: ExtractionAgentSession, + user_message: str, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + yield { + "type": "thinking", + "recent": [ + "Starting sticky session Claude agent runtime…", + f"Applying {ui_mode.value} skill overlay", + ], + } + yield { + "type": "thinking", + "recent": [ + "Starting sticky session Claude agent runtime…", + f"Applying {ui_mode.value} skill overlay", + "Reviewing session message history", + ], + } + skills = session.runtime_context.get("agent_configuration", {}).get("skills", {}) + skill_keys = ", ".join(sorted(skills.keys())[:3]) or "default skills" + reply = ( + f"**Graph Management Assistant ({ui_mode.value})**\n\n" + f"I received your message and loaded skills: {skill_keys}.\n\n" + f"> {user_message.strip()}\n\n" + "This is a tracer-bullet reply. The sticky container runtime will invoke " + "the Claude Agent SDK with JobPackage context in a follow-up change." + ) + yield {"type": "done", "ok": True, "reply": reply} diff --git a/src/api/extraction/infrastructure/ingestion_readiness_reader.py b/src/api/extraction/infrastructure/ingestion_readiness_reader.py new file mode 100644 index 000000000..a89908379 --- /dev/null +++ b/src/api/extraction/infrastructure/ingestion_readiness_reader.py @@ -0,0 +1,36 @@ +"""SQL reader for ingestion prepare counts without importing Management.""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from extraction.domain.value_objects import IngestionReadinessSnapshot + + +class SqlIngestionReadinessReader: + """Reads prepared data source counts from the shared data_sources table.""" + + def __init__(self, *, session: AsyncSession) -> None: + self._session = session + + async def read_for_knowledge_graph( + self, *, knowledge_graph_id: str + ) -> IngestionReadinessSnapshot: + result = await self._session.execute( + text( + """ + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE last_prepared_commit IS NOT NULL) AS prepared + FROM data_sources + WHERE knowledge_graph_id = :knowledge_graph_id + """ + ), + {"knowledge_graph_id": knowledge_graph_id}, + ) + row = result.one() + return IngestionReadinessSnapshot( + data_source_count=int(row.total or 0), + prepared_source_count=int(row.prepared or 0), + ) diff --git a/src/api/extraction/ports/chat_agent.py b/src/api/extraction/ports/chat_agent.py new file mode 100644 index 000000000..5729f4b4e --- /dev/null +++ b/src/api/extraction/ports/chat_agent.py @@ -0,0 +1,23 @@ +"""Port contract for graph-management chat agent execution.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, Protocol + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import GraphManagementUiMode + + +class IExtractionChatAgent(Protocol): + """Runs one conversational turn inside a sticky session runtime.""" + + def stream_turn( + self, + *, + session: ExtractionAgentSession, + user_message: str, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + """Yield NDJSON-style event dictionaries ending with a terminal done event.""" + ... diff --git a/src/api/extraction/ports/ingestion_readiness.py b/src/api/extraction/ports/ingestion_readiness.py new file mode 100644 index 000000000..fa75f8cb9 --- /dev/null +++ b/src/api/extraction/ports/ingestion_readiness.py @@ -0,0 +1,17 @@ +"""Port for reading ingestion prepare readiness without importing Management.""" + +from __future__ import annotations + +from typing import Protocol + +from extraction.domain.value_objects import IngestionReadinessSnapshot + + +class IIngestionReadinessReader(Protocol): + """Read-only ingestion prepare counts for JobPackage gating.""" + + async def read_for_knowledge_graph( + self, *, knowledge_graph_id: str + ) -> IngestionReadinessSnapshot: + """Return data source totals and prepared counts for one knowledge graph.""" + ... diff --git a/src/api/extraction/presentation/models.py b/src/api/extraction/presentation/models.py index 214deb3d4..e912f19f2 100644 --- a/src/api/extraction/presentation/models.py +++ b/src/api/extraction/presentation/models.py @@ -13,6 +13,7 @@ BootstrapIntakePath, ExtractionSessionMode, ExtractionSessionRunMetric, + GraphManagementUiMode, ) @@ -129,3 +130,10 @@ class BootstrapIntakePathSelectionRequest(BaseModel): default=None, description="Optional user summary of capabilities and schema goals", ) + + +class ExtractionChatTurnRequest(BaseModel): + """Request model for a graph-management chat turn.""" + + message: str = Field(min_length=1) + graph_management_ui_mode: GraphManagementUiMode = GraphManagementUiMode.INITIAL_SCHEMA_DESIGN diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py index 0db6810af..7ccae2a17 100644 --- a/src/api/extraction/presentation/routes.py +++ b/src/api/extraction/presentation/routes.py @@ -2,15 +2,22 @@ from __future__ import annotations +import json from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse from extraction.application import ExtractionAgentSessionService -from extraction.dependencies import get_extraction_agent_session_service +from extraction.application.chat_turn_service import ExtractionChatTurnService +from extraction.dependencies import ( + get_extraction_agent_session_service, + get_extraction_chat_turn_service, +) from extraction.domain.value_objects import ExtractionSessionMode from extraction.presentation.models import ( BootstrapIntakePathSelectionRequest, + ExtractionChatTurnRequest, ExtractionSessionHistoryItemResponse, ExtractionSessionHistoryResponse, ExtractionSessionListResponse, @@ -155,6 +162,36 @@ async def clear_chat( return ExtractionSessionResponse.from_domain(session) +@router.post( + "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/chat", +) +async def stream_chat_turn( + knowledge_graph_id: str, + mode: ExtractionSessionMode, + request: ExtractionChatTurnRequest, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ExtractionChatTurnService, Depends(get_extraction_chat_turn_service)], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> StreamingResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + + async def event_stream(): + async for event in service.stream_chat_turn( + user_id=current_user.user_id.value, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=request.graph_management_ui_mode, + message=request.message, + ): + yield json.dumps(event) + "\n" + + return StreamingResponse(event_stream(), media_type="application/x-ndjson") + + @router.post( "/knowledge-graphs/{knowledge_graph_id}/sessions/schema_bootstrap/active/intake-path", response_model=ExtractionSessionResponse, diff --git a/src/api/tests/unit/extraction/application/test_chat_turn_service.py b/src/api/tests/unit/extraction/application/test_chat_turn_service.py new file mode 100644 index 000000000..a807b62a2 --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_chat_turn_service.py @@ -0,0 +1,151 @@ +"""Unit tests for ExtractionChatTurnService.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.chat_turn_service import ExtractionChatTurnService +from extraction.application.skill_resolution_service import ExtractionSkillResolutionService +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ( + ExtractionSessionMode, + GraphManagementUiMode, + IngestionReadinessSnapshot, +) +from extraction.infrastructure.deterministic_chat_agent import DeterministicExtractionChatAgent +from extraction.infrastructure.workload_runtime import InMemoryStickySessionRuntimeManager + + +class _InMemoryAgentSessionRepository: + def __init__(self) -> None: + self._sessions: dict[str, ExtractionAgentSession] = {} + + async def save(self, session: ExtractionAgentSession) -> None: + self._sessions[session.id] = replace(session) + + async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: + session = self._sessions.get(session_id) + return replace(session) if session else None + + async def find_active_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ) -> ExtractionAgentSession | None: + for session in self._sessions.values(): + if ( + session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and session.mode == mode + and session.archived_at is None + ): + return replace(session) + return None + + async def list_by_scope( + self, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode | None = None, + ) -> list[ExtractionAgentSession]: + return [] + + +class _StaticIngestionReadinessReader: + def __init__(self, snapshot: IngestionReadinessSnapshot) -> None: + self._snapshot = snapshot + + async def read_for_knowledge_graph( + self, *, knowledge_graph_id: str + ) -> IngestionReadinessSnapshot: + return self._snapshot + + +class _StaticSkillResolutionService: + async def resolve_for_graph_management_turn(self, **kwargs): + return type( + "_Resolved", + (), + { + "system_prompt": "system", + "prompt_hierarchy": ("platform",), + "guardrails": ("scope",), + "skills": {"ui_mode_framing": "test overlay"}, + }, + )() + + +@pytest.mark.asyncio +async def test_stream_chat_turn_persists_assistant_reply() -> None: + repo = _InMemoryAgentSessionRepository() + sticky = InMemoryStickySessionRuntimeManager() + session_service = ExtractionAgentSessionService(repository=repo) + service = ExtractionChatTurnService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader( + IngestionReadinessSnapshot(1, 1), + ), + sticky_runtime_manager=sticky, + chat_agent=DeterministicExtractionChatAgent(), + ) + + events = [ + event + async for event in service.stream_chat_turn( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + message="Help me design entity types", + ) + ] + + assert events[-1]["type"] == "done" + assert events[-1]["ok"] is True + active = await repo.find_active_by_scope("user-1", "kg-1", ExtractionSessionMode.SCHEMA_BOOTSTRAP) + assert active is not None + assert active.message_history[-2]["role"] == "user" + assert active.message_history[-1]["role"] == "assistant" + assert active.runtime_context["sticky_runtime"]["container_id"] + + +@pytest.mark.asyncio +async def test_stream_chat_turn_wait_when_job_package_unprepared() -> None: + repo = _InMemoryAgentSessionRepository() + sticky = InMemoryStickySessionRuntimeManager() + session_service = ExtractionAgentSessionService(repository=repo) + service = ExtractionChatTurnService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader( + IngestionReadinessSnapshot(2, 0), + ), + sticky_runtime_manager=sticky, + chat_agent=DeterministicExtractionChatAgent(), + ) + + events = [ + event + async for event in service.stream_chat_turn( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, + ui_mode=GraphManagementUiMode.EXTRACTION_JOBS, + message="Run extraction on repo files", + ) + ] + + assert any(event.get("type") == "wait" for event in events) + done = events[-1] + assert done["ok"] is True + assert done.get("wait") is True + active = await repo.find_active_by_scope( + "user-1", "kg-1", ExtractionSessionMode.EXTRACTION_OPERATIONS + ) + assert active is not None + assert active.runtime_context["job_package"]["phase"] == "awaiting_job_package" diff --git a/src/api/tests/unit/extraction/application/test_job_package_gate.py b/src/api/tests/unit/extraction/application/test_job_package_gate.py new file mode 100644 index 000000000..96106e496 --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_job_package_gate.py @@ -0,0 +1,38 @@ +"""Unit tests for JobPackage gate resolution.""" + +from __future__ import annotations + +from extraction.application.job_package_gate import ( + IngestionReadinessSnapshot, + resolve_job_package_gate, +) +from extraction.domain.value_objects import ( + GraphManagementUiMode, + SessionJobPackagePhase, +) + + +def test_schema_design_does_not_require_job_package() -> None: + decision = resolve_job_package_gate( + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + readiness=IngestionReadinessSnapshot(0, 0), + ) + assert decision.phase == SessionJobPackagePhase.NOT_REQUIRED + + +def test_extraction_jobs_waits_without_prepared_sources() -> None: + decision = resolve_job_package_gate( + ui_mode=GraphManagementUiMode.EXTRACTION_JOBS, + readiness=IngestionReadinessSnapshot(data_source_count=2, prepared_source_count=1), + ) + assert decision.phase == SessionJobPackagePhase.AWAITING_PREPARE + assert decision.wait_message is not None + assert "JobPackage" in decision.wait_message + + +def test_extraction_jobs_ready_when_all_prepared() -> None: + decision = resolve_job_package_gate( + ui_mode=GraphManagementUiMode.EXTRACTION_JOBS, + readiness=IngestionReadinessSnapshot(data_source_count=2, prepared_source_count=2), + ) + assert decision.phase == SessionJobPackagePhase.READY From 8dfb95c6b1ae546b9afc86005605cb72b638d066 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Thu, 28 May 2026 22:41:21 -0400 Subject: [PATCH 62/80] feat(ui): wire Graph Management Assistant to streaming chat API (#745) Stream NDJSON chat turns with thinking/wait activity lines and reload session history after each turn. Closes #741. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../pages/knowledge-graphs/[kgId]/manage.vue | 82 +++++++++++++++---- .../app/tests/kg-extraction-chat.test.ts | 43 ++++++++++ .../knowledge-graph-manage-workspace.test.ts | 1 + src/dev-ui/app/utils/kgExtractionChat.ts | 82 +++++++++++++++++++ 4 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 src/dev-ui/app/tests/kg-extraction-chat.test.ts create mode 100644 src/dev-ui/app/utils/kgExtractionChat.ts diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 2f9e65609..47e6995d8 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -96,8 +96,12 @@ import { type MutationLogEntryPreviewPage, type MutationLogRunRecord, } from '@/utils/kgMutationLogs' +import { streamExtractionChatTurn } from '@/utils/kgExtractionChat' import { useGraphApi } from '@/composables/api/useGraphApi' +const runtimeConfig = useRuntimeConfig() +const { accessToken } = useAuth() + interface WorkspaceReadinessStatus { has_minimum_entity_types: boolean has_minimum_relationship_types: boolean @@ -180,7 +184,7 @@ interface ExtractionSessionHistoryItem { const route = useRoute() const { hasTenant, tenantVersion } = useTenant() const { extractErrorMessage } = useErrorHandler() -const { apiFetch } = useApiClient() +const { apiFetch, currentTenantId } = useApiClient() const graphApi = useGraphApi() const kgId = computed(() => String(route.params.kgId ?? '')) const kgIdentity = ref<KnowledgeGraphIdentity | null>(null) @@ -441,12 +445,7 @@ const nextSteps = computed(() => { return steps }) -const sessionActivityLines = computed(() => { - const context = extractionSession.value?.runtime_context ?? {} - const candidate = context.activity_lines ?? context.ndjson_activity_lines ?? context.thinking_lines - if (!Array.isArray(candidate)) return [] - return candidate.filter((line): line is string => typeof line === 'string' && line.trim().length > 0) -}) +const sessionActivityLines = ref<string[]>([]) async function loadKgIdentity() { if (!hasTenant.value || !kgId.value) return @@ -777,6 +776,7 @@ async function loadExtractionSession() { extractionSession.value = await apiFetch<ExtractionSessionResponse>( `/extraction/knowledge-graphs/${kgId.value}/sessions/${sharedSessionMode.value}/active`, ) + syncActivityLinesFromSession() sessionForbidden.value = false sessionForbiddenReason.value = null } catch (err) { @@ -871,7 +871,19 @@ function onMutationRunKeydown(event: KeyboardEvent, runId: string) { handleActivatableKeydown(event, () => selectMutationLogRun(runId)) } -function sendChatMessage(message: string) { +function syncActivityLinesFromSession() { + const context = extractionSession.value?.runtime_context ?? {} + const candidate = context.activity_lines ?? context.ndjson_activity_lines ?? context.thinking_lines + if (Array.isArray(candidate)) { + sessionActivityLines.value = candidate.filter( + (line): line is string => typeof line === 'string' && line.trim().length > 0, + ) + } else { + sessionActivityLines.value = [] + } +} + +async function sendChatMessage(message: string) { if (sessionForbidden.value || !shouldApplyMutationResult(sessionForbidden.value)) { toast.error('Chat unavailable', { description: sessionForbiddenReason.value @@ -880,21 +892,55 @@ function sendChatMessage(message: string) { return } + const trimmed = message.trim() + if (!trimmed || !kgId.value) return + sendingChat.value = true - try { - const nextHistory = appendLocalChatMessage(extractionSession.value, message) - extractionSession.value = { - ...(extractionSession.value ?? { - id: 'local-session', - runtime_context: {}, - updated_at: new Date().toISOString(), - }), - message_history: nextHistory, + sessionActivityLines.value = ['Contacting Graph Management Assistant…'] + draftMessage.value = '' + + const optimisticHistory = appendLocalChatMessage(extractionSession.value, trimmed) + extractionSession.value = { + ...(extractionSession.value ?? { + id: 'pending-session', + runtime_context: {}, updated_at: new Date().toISOString(), + }), + message_history: optimisticHistory, + updated_at: new Date().toISOString(), + } + + try { + for await (const event of streamExtractionChatTurn({ + apiBaseUrl: String(runtimeConfig.public.apiBaseUrl ?? ''), + accessToken: accessToken.value, + tenantId: currentTenantId.value, + kgId: kgId.value, + sessionMode: sharedSessionMode.value, + uiMode: graphManagementMode.value, + message: trimmed, + })) { + if (event.type === 'thinking' && Array.isArray(event.recent)) { + sessionActivityLines.value = event.recent.filter(Boolean) + } + if (event.type === 'wait') { + sessionActivityLines.value = event.message + ? [event.message] + : ['Waiting for JobPackage ingestion context…'] + } + if (event.type === 'done' && event.ok !== true) { + throw new Error(event.error?.message ?? 'Graph Management Assistant returned an error.') + } } - draftMessage.value = '' + await loadExtractionSession() + } catch (err) { + toast.error('Failed to send message', { + description: extractErrorMessage(err), + }) + await loadExtractionSession() } finally { sendingChat.value = false + syncActivityLinesFromSession() } } diff --git a/src/dev-ui/app/tests/kg-extraction-chat.test.ts b/src/dev-ui/app/tests/kg-extraction-chat.test.ts new file mode 100644 index 000000000..efdfd0a37 --- /dev/null +++ b/src/dev-ui/app/tests/kg-extraction-chat.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { streamExtractionChatTurn } from '../utils/kgExtractionChat' + +describe('kgExtractionChat', () => { + it('targets the extraction chat NDJSON endpoint with UI mode in body', async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), init }) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"type":"done","ok":true,"reply":"hi"}\n')) + controller.close() + }, + }) + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/x-ndjson' } }) + }) as typeof fetch + + try { + const events = [] + for await (const event of streamExtractionChatTurn({ + apiBaseUrl: 'http://api.test', + accessToken: 'token', + tenantId: 'tenant-1', + kgId: 'kg-1', + sessionMode: 'schema_bootstrap', + uiMode: 'initial-schema-design', + message: 'Hello', + })) { + events.push(event) + } + + expect(events).toEqual([{ type: 'done', ok: true, reply: 'hi' }]) + expect(calls[0]?.url).toContain('/extraction/knowledge-graphs/kg-1/sessions/schema_bootstrap/chat') + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + message: 'Hello', + graph_management_ui_mode: 'initial-schema-design', + }) + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 2b0d67374..aa4ef8086 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -552,6 +552,7 @@ describe('KG-MANAGE-017 - chat input keyboard contract', () => { expect(sharedConversationPanelVue).toContain('@keydown.enter="handleComposerEnter"') expect(sharedConversationPanelVue).toContain('Shift+Enter for a new line') expect(sharedConversationPanelVue).toContain("emit('sendMessage'") + expect(manageWorkspaceVue).toContain('streamExtractionChatTurn') expect(manageWorkspaceVue).toContain('@send-message="sendChatMessage"') }) }) diff --git a/src/dev-ui/app/utils/kgExtractionChat.ts b/src/dev-ui/app/utils/kgExtractionChat.ts new file mode 100644 index 000000000..9ed1cd13a --- /dev/null +++ b/src/dev-ui/app/utils/kgExtractionChat.ts @@ -0,0 +1,82 @@ +/** Stream graph-management chat turns over NDJSON. */ + +import type { GraphManagementMode } from '@/utils/kgGraphManagement' + +export interface ExtractionChatStreamEvent { + type: 'thinking' | 'wait' | 'done' + recent?: string[] + phase?: string + message?: string + ok?: boolean + reply?: string | null + wait?: boolean + error?: { code: string; message: string } +} + +export interface StreamExtractionChatOptions { + apiBaseUrl: string + accessToken: string | null + tenantId: string | null + kgId: string + sessionMode: 'schema_bootstrap' | 'extraction_operations' + uiMode: GraphManagementMode + message: string +} + +export async function* streamExtractionChatTurn( + options: StreamExtractionChatOptions, +): AsyncGenerator<ExtractionChatStreamEvent> { + const headers: Record<string, string> = { + 'Content-Type': 'application/json', + Accept: 'application/x-ndjson', + } + if (options.accessToken) { + headers.Authorization = `Bearer ${options.accessToken}` + } + if (options.tenantId) { + headers['X-Tenant-ID'] = options.tenantId + } + + const response = await fetch( + `${options.apiBaseUrl}/extraction/knowledge-graphs/${encodeURIComponent(options.kgId)}/sessions/${options.sessionMode}/chat`, + { + method: 'POST', + headers, + body: JSON.stringify({ + message: options.message, + graph_management_ui_mode: options.uiMode, + }), + }, + ) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(body || `${response.status} ${response.statusText}`) + } + + const reader = response.body?.getReader() + if (!reader) { + throw new Error('No response body from Graph Management Assistant') + } + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n') + buffer = parts.pop() ?? '' + for (const line of parts) { + const trimmed = line.trim() + if (!trimmed) continue + yield JSON.parse(trimmed) as ExtractionChatStreamEvent + } + } + + const tail = buffer.trim() + if (tail) { + yield JSON.parse(tail) as ExtractionChatStreamEvent + } +} From 687004b2e8bafec2e9e14fb683b7a33e31600d62 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Thu, 28 May 2026 23:34:44 -0400 Subject: [PATCH 63/80] feat(extraction): add Claude Agent SDK sticky session runtime (#742) (#746) Ship kartograph-agent-runtime container image with NDJSON turn API, mount skills and JobPackage workspaces, inject chat-scoped workload tokens, and delegate graph-management chat turns to the remote runtime when container backend is enabled. Closes #742. Co-authored-by: Cursor <cursoragent@cursor.com> --- compose.dev.yaml | 12 + src/agent-runtime/Dockerfile | 20 + .../kartograph_agent_runtime/__init__.py | 1 + .../kartograph_agent_runtime/__main__.py | 21 + .../kartograph_agent_runtime/executor.py | 112 ++ .../kartograph_agent_runtime/server.py | 44 + .../kartograph_agent_runtime/settings.py | 23 + .../kartograph_agent_runtime/tools.py | 45 + src/agent-runtime/pyproject.toml | 21 + src/agent-runtime/tests/test_executor.py | 33 + src/agent-runtime/uv.lock | 1446 +++++++++++++++++ .../application/chat_turn_service.py | 52 +- src/api/extraction/dependencies.py | 22 +- .../container_workload_runtime.py | 52 + .../prepared_job_package_reader.py | 37 + .../remote_sticky_container_chat_agent.py | 78 + .../sticky_session_bootstrap_builder.py | 64 + .../sticky_session_workdir_materializer.py | 67 + .../infrastructure/workload_runtime.py | 51 +- .../workload_runtime_factory.py | 30 + .../workload_runtime_settings.py | 11 +- .../extraction/ports/prepared_job_packages.py | 15 + src/api/extraction/ports/runtime.py | 14 + .../ports/sticky_session_bootstrap.py | 22 + src/api/extraction/ports/workload_graph.py | 31 + src/api/extraction/presentation/__init__.py | 3 +- src/api/extraction/presentation/routes.py | 1 + .../extraction/presentation/workload_auth.py | 69 + .../presentation/workload_routes.py | 99 ++ .../extraction_workload/dependencies.py | 31 + .../extraction_workload/graph_reader.py | 61 + src/api/main.py | 6 +- .../container_runtime/cli_runtime.py | 4 + .../shared_kernel/container_runtime/ports.py | 2 + .../application/test_chat_turn_service.py | 9 + ...test_sticky_session_container_bootstrap.py | 49 + ...est_sticky_session_workdir_materializer.py | 59 + .../test_workload_credential_issuer.py | 23 + .../container_runtime/test_cli_runtime.py | 28 + 39 files changed, 2731 insertions(+), 37 deletions(-) create mode 100644 src/agent-runtime/Dockerfile create mode 100644 src/agent-runtime/kartograph_agent_runtime/__init__.py create mode 100644 src/agent-runtime/kartograph_agent_runtime/__main__.py create mode 100644 src/agent-runtime/kartograph_agent_runtime/executor.py create mode 100644 src/agent-runtime/kartograph_agent_runtime/server.py create mode 100644 src/agent-runtime/kartograph_agent_runtime/settings.py create mode 100644 src/agent-runtime/kartograph_agent_runtime/tools.py create mode 100644 src/agent-runtime/pyproject.toml create mode 100644 src/agent-runtime/tests/test_executor.py create mode 100644 src/agent-runtime/uv.lock create mode 100644 src/api/extraction/infrastructure/prepared_job_package_reader.py create mode 100644 src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py create mode 100644 src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py create mode 100644 src/api/extraction/infrastructure/sticky_session_workdir_materializer.py create mode 100644 src/api/extraction/ports/prepared_job_packages.py create mode 100644 src/api/extraction/ports/sticky_session_bootstrap.py create mode 100644 src/api/extraction/ports/workload_graph.py create mode 100644 src/api/extraction/presentation/workload_auth.py create mode 100644 src/api/extraction/presentation/workload_routes.py create mode 100644 src/api/infrastructure/extraction_workload/dependencies.py create mode 100644 src/api/infrastructure/extraction_workload/graph_reader.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_workload_credential_issuer.py diff --git a/compose.dev.yaml b/compose.dev.yaml index ab8e5bf51..51fb575c0 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,5 +1,12 @@ # Development overrides for compose.yaml services: + agent-runtime: + build: + context: ./src/agent-runtime + dockerfile: Dockerfile + image: kartograph-agent-runtime:dev + profiles: ["build-only"] + api: # Run as root in dev to handle host file permissions (any umask) user: "${UID}:${GID}" @@ -7,6 +14,11 @@ services: UV_CACHE_DIR: /tmp/uv-cache KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND: container KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE: auto + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_NETWORK: kartograph_kartograph + KARTOGRAPH_EXTRACTION_RUNTIME_STICKY_IMAGE: kartograph-agent-runtime:dev + KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL: http://api:8000 + KARTOGRAPH_EXTRACTION_RUNTIME_JOB_PACKAGE_WORK_DIR: /tmp/kartograph/job_packages + KARTOGRAPH_EXTRACTION_RUNTIME_SKILLS_DIR: /app/skills volumes: # Mount the entire app directory (minus venv) for hot-reload - ./src/api:/app:z diff --git a/src/agent-runtime/Dockerfile b/src/agent-runtime/Dockerfile new file mode 100644 index 000000000..54172815e --- /dev/null +++ b/src/agent-runtime/Dockerfile @@ -0,0 +1,20 @@ +FROM registry.access.redhat.com/ubi9/python-312:latest + +WORKDIR /runtime + +COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /uvx /bin/ + +COPY pyproject.toml /runtime/pyproject.toml +COPY kartograph_agent_runtime /runtime/kartograph_agent_runtime + +RUN uv sync --no-dev + +ENV PATH="/runtime/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 + +EXPOSE 8787 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health').read()" || exit 1 + +CMD ["python", "-m", "kartograph_agent_runtime"] diff --git a/src/agent-runtime/kartograph_agent_runtime/__init__.py b/src/agent-runtime/kartograph_agent_runtime/__init__.py new file mode 100644 index 000000000..89889fd90 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/__init__.py @@ -0,0 +1 @@ +"""Kartograph sticky session Claude Agent SDK runtime.""" diff --git a/src/agent-runtime/kartograph_agent_runtime/__main__.py b/src/agent-runtime/kartograph_agent_runtime/__main__.py new file mode 100644 index 000000000..13ce20acb --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/__main__.py @@ -0,0 +1,21 @@ +"""CLI entrypoint for sticky session agent runtime.""" + +from __future__ import annotations + +import uvicorn + +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +def main() -> None: + settings = AgentRuntimeSettings() + uvicorn.run( + "kartograph_agent_runtime.server:app", + host=settings.host, + port=settings.port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py new file mode 100644 index 000000000..935cf44e7 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -0,0 +1,112 @@ +"""Turn execution for sticky session chat using Claude Agent SDK or fallback mode.""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from typing import Any + +from kartograph_agent_runtime.settings import AgentRuntimeSettings +from kartograph_agent_runtime.tools import RuntimeTooling + + +def _build_system_prompt(agent_configuration: dict[str, Any]) -> str: + system_prompt = str(agent_configuration.get("system_prompt") or "").strip() + guardrails = agent_configuration.get("guardrails") or [] + skills = agent_configuration.get("skills") or {} + skill_lines = "\n".join(f"- {key}: {value}" for key, value in sorted(skills.items())) + guardrail_lines = "\n".join(f"- {item}" for item in guardrails if str(item).strip()) + sections = [section for section in (system_prompt, guardrail_lines, skill_lines) if section] + return "\n\n".join(sections) or "You are the Graph Management Assistant." + + +async def stream_turn_events( + *, + settings: AgentRuntimeSettings, + message: str, + ui_mode: str, + agent_configuration: dict[str, Any], + message_history: list[dict[str, Any]], +) -> AsyncIterator[dict[str, Any]]: + yield { + "type": "thinking", + "recent": [ + "Starting Claude Agent SDK runtime…", + f"Applying {ui_mode} skill overlay", + f"Workspace mounted at {settings.workspace_dir}", + ], + } + + if settings.anthropic_api_key: + async for event in _stream_with_claude_sdk( + settings=settings, + message=message, + ui_mode=ui_mode, + agent_configuration=agent_configuration, + message_history=message_history, + ): + yield event + return + + tooling = RuntimeTooling(settings=settings) + skill_keys = ", ".join(sorted(agent_configuration.get("skills", {}).keys())[:4]) or "default" + reply = ( + f"**Graph Management Assistant ({ui_mode})**\n\n" + f"I received your message with skills: {skill_keys}.\n\n" + f"> {message.strip()}\n\n" + "Claude Agent SDK is configured for this container. Set `ANTHROPIC_API_KEY` " + "to enable live model execution. Graph and mutation tools are wired via " + f"`{settings.api_base_url}` using the injected workload token." + ) + if message.lower().startswith("search graph:"): + slug = message.split(":", 1)[1].strip() + try: + graph_result = await tooling.search_graph_by_slug(slug=slug) + reply += f"\n\nGraph search returned {graph_result.get('count', 0)} node(s)." + except Exception as exc: # noqa: BLE001 + reply += f"\n\nGraph search failed: {exc}" + yield {"type": "done", "ok": True, "reply": reply} + + +async def _stream_with_claude_sdk( + *, + settings: AgentRuntimeSettings, + message: str, + ui_mode: str, + agent_configuration: dict[str, Any], + message_history: list[dict[str, Any]], +) -> AsyncIterator[dict[str, Any]]: + from claude_agent_sdk import ClaudeAgentOptions, query + + os.environ.setdefault("ANTHROPIC_API_KEY", settings.anthropic_api_key) + system_prompt = _build_system_prompt(agent_configuration) + history_lines = [ + f"{entry.get('role', 'unknown')}: {entry.get('content', '')}" + for entry in message_history[-6:] + if isinstance(entry, dict) + ] + prompt = message + if history_lines: + prompt = "Recent conversation:\n" + "\n".join(history_lines) + f"\n\nUser: {message}" + + yield { + "type": "thinking", + "recent": [ + "Claude Agent SDK query started…", + f"Mode overlay: {ui_mode}", + "Tools: graph read enclave, mutation emitter", + ], + } + + chunks: list[str] = [] + options = ClaudeAgentOptions(system_prompt=system_prompt) + async for sdk_message in query(prompt=prompt, options=options): + text = getattr(sdk_message, "result", None) or getattr(sdk_message, "content", None) + if isinstance(text, str) and text.strip(): + chunks.append(text.strip()) + + reply = chunks[-1] if chunks else ( + "Claude Agent SDK completed without a textual response. " + "Retry with a more specific graph-management request." + ) + yield {"type": "done", "ok": True, "reply": reply} diff --git a/src/agent-runtime/kartograph_agent_runtime/server.py b/src/agent-runtime/kartograph_agent_runtime/server.py new file mode 100644 index 000000000..7df4322f5 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/server.py @@ -0,0 +1,44 @@ +"""HTTP server for sticky session agent runtime.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from typing import Any + +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from kartograph_agent_runtime.executor import stream_turn_events +from kartograph_agent_runtime.settings import AgentRuntimeSettings + +app = FastAPI(title="Kartograph Agent Runtime", version="0.1.0") +settings = AgentRuntimeSettings() + + +class TurnRequest(BaseModel): + message: str = Field(min_length=1) + ui_mode: str = Field(default="initial-schema-design") + agent_configuration: dict[str, Any] = Field(default_factory=dict) + message_history: list[dict[str, Any]] = Field(default_factory=list) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "session_id": settings.session_id} + + +@app.post("/v1/turn") +async def stream_turn(request: TurnRequest) -> StreamingResponse: + async def event_stream() -> AsyncIterator[str]: + async for event in stream_turn_events( + settings=settings, + message=request.message, + ui_mode=request.ui_mode, + agent_configuration=request.agent_configuration, + message_history=request.message_history, + ): + yield json.dumps(event) + "\n" + + return StreamingResponse(event_stream(), media_type="application/x-ndjson") diff --git a/src/agent-runtime/kartograph_agent_runtime/settings.py b/src/agent-runtime/kartograph_agent_runtime/settings.py new file mode 100644 index 000000000..f37c53ee8 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/settings.py @@ -0,0 +1,23 @@ +"""Agent runtime settings loaded from container environment.""" + +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AgentRuntimeSettings(BaseSettings): + """Runtime configuration for sticky session agent containers.""" + + model_config = SettingsConfigDict(extra="ignore") + + host: str = Field(default="0.0.0.0") + port: int = Field(default=8787) + api_base_url: str = Field(default="http://api:8000", alias="KARTOGRAPH_API_BASE_URL") + workload_token: str = Field(default="", alias="KARTOGRAPH_WORKLOAD_TOKEN") + tenant_id: str = Field(default="", alias="KARTOGRAPH_TENANT_ID") + knowledge_graph_id: str = Field(default="", alias="KARTOGRAPH_KNOWLEDGE_GRAPH_ID") + session_id: str = Field(default="", alias="KARTOGRAPH_SESSION_ID") + skills_dir: str = Field(default="/app/skills", alias="KARTOGRAPH_SKILLS_DIR") + workspace_dir: str = Field(default="/workspace", alias="KARTOGRAPH_WORKSPACE_DIR") + anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY") diff --git a/src/agent-runtime/kartograph_agent_runtime/tools.py b/src/agent-runtime/kartograph_agent_runtime/tools.py new file mode 100644 index 000000000..1b544fcce --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/tools.py @@ -0,0 +1,45 @@ +"""Tool wiring for graph read enclave and mutation emitters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +@dataclass(frozen=True) +class RuntimeTooling: + """HTTP-backed tools available to the Claude agent runtime.""" + + settings: AgentRuntimeSettings + + async def search_graph_by_slug( + self, *, slug: str, entity_type: str | None = None + ) -> dict[str, Any]: + headers = {"X-Workload-Token": self.settings.workload_token} + params: dict[str, str] = {"slug": slug} + if entity_type: + params["entity_type"] = entity_type + url = f"{self.settings.api_base_url.rstrip('/')}/extraction/workloads/graph/search-by-slug" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + + async def propose_mutation( + self, *, operation: str, summary: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + headers = {"X-Workload-Token": self.settings.workload_token} + url = f"{self.settings.api_base_url.rstrip('/')}/extraction/workloads/mutations/propose" + body = { + "operation": operation, + "summary": summary, + "payload": payload or {}, + } + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=headers, json=body) + response.raise_for_status() + return response.json() diff --git a/src/agent-runtime/pyproject.toml b/src/agent-runtime/pyproject.toml new file mode 100644 index 000000000..e1de64bc5 --- /dev/null +++ b/src/agent-runtime/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "kartograph-agent-runtime" +version = "0.1.0" +description = "Sticky session Claude Agent SDK runtime for Kartograph graph management chat" +requires-python = ">=3.12" +dependencies = [ + "claude-agent-sdk>=0.2.87", + "fastapi[standard]>=0.123.9", + "httpx>=0.28.1", + "pydantic-settings>=2.12.0", +] + +[dependency-groups] +dev = [ + "pytest>=9.0.1", + "pytest-asyncio>=1.3.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "strict" +pythonpath = ["."] diff --git a/src/agent-runtime/tests/test_executor.py b/src/agent-runtime/tests/test_executor.py new file mode 100644 index 000000000..d754d2599 --- /dev/null +++ b/src/agent-runtime/tests/test_executor.py @@ -0,0 +1,33 @@ +"""Unit tests for agent runtime executor fallback mode.""" + +from __future__ import annotations + +import pytest + +from kartograph_agent_runtime.executor import stream_turn_events +from kartograph_agent_runtime.settings import AgentRuntimeSettings + + +@pytest.mark.asyncio +async def test_stream_turn_events_without_api_key_returns_done_reply() -> None: + settings = AgentRuntimeSettings( + KARTOGRAPH_WORKLOAD_TOKEN="token", + KARTOGRAPH_API_BASE_URL="http://api:8000", + ANTHROPIC_API_KEY="", + ) + + events = [ + event + async for event in stream_turn_events( + settings=settings, + message="Design entity types", + ui_mode="initial-schema-design", + agent_configuration={"skills": {"schema_modeling": "guide"}}, + message_history=[], + ) + ] + + assert events[0]["type"] == "thinking" + assert events[-1]["type"] == "done" + assert events[-1]["ok"] is True + assert "Graph Management Assistant" in events[-1]["reply"] diff --git a/src/agent-runtime/uv.lock b/src/agent-runtime/uv.lock new file mode 100644 index 000000000..55db0662d --- /dev/null +++ b/src/agent-runtime/uv.lock @@ -0,0 +1,1446 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/dc/e2afd59a1dd6484b6500245fa2331a0d8c0b68e6c180bc29d8ce9540f38a/claude_agent_sdk-0.2.87.tar.gz", hash = "sha256:56f02a49a97f7be37e0cd7323494d1c09e52fb0db7ab94f53bba8a230bb4bd0e", size = 252063, upload-time = "2026-05-23T04:19:25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4e/b83c4c6ec1e0b63e9d4d58ba9a5abfd9936c55b8ee4c06b88f5e93bdfd70/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:52204a9609dec3aa96032afd48c07d72e05d13311faf614978f17b61326e6e31", size = 63037960, upload-time = "2026-05-23T04:19:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/d7/5fb02260c5b95c66e108c35e046d4d66011921251f7896274b6b21594f14/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1713e34e50b830ecac54386d39af14e3a2775f833f1ef715eb53566eaa1b6325", size = 65095745, upload-time = "2026-05-23T04:19:32.533Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/1061f6580bbbc78de629467abf051cdbbabe71b982297b401e3fde65c7e0/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e9e23119d2a02ad1ea1a2707214db98f5baf2c8809577186629843ddfcb8ec18", size = 72725120, upload-time = "2026-05-23T04:19:36.539Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/449f5044d76d9de18cf6a9f4b1c9386a74f41b4e2da5312df245d9dd23ef/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:5ac525d9ae3481296df5639d005e12ce2b6b0427426991f35da64db30be25c6e", size = 72875504, upload-time = "2026-05-23T04:19:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/3f9d7c491d5a98138d293192b31cc9ed792d3552b3a7e276163d7fe2d43a/claude_agent_sdk-0.2.87-py3-none-win_amd64.whl", hash = "sha256:f34973669a1efaeb1543e7b22d7b22feefd8af2fae3adfd39181635077dae432", size = 73514880, upload-time = "2026-05-23T04:19:44.65Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/1d/57221a834b0f62dfa510c2b3db6e9b682cfbc280cef41919a8811ce1ff89/fastapi_cloud_cli-0.18.0.tar.gz", hash = "sha256:95f7a79200e3a90a005e068a4d8ede49d4b04accb095ccd4fd47da998fc28c74", size = 53320, upload-time = "2026-05-22T09:53:54.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/1e/1d54aabf71c003e89e73df92c3dfded311228e68db7cea5db90b3e0ef2b5/fastapi_cloud_cli-0.18.0-py3-none-any.whl", hash = "sha256:1f136fc651b0b6e2f4a9679e23c56e1c3be3405e74469c14ba6e2d5b87fdc113", size = 37087, upload-time = "2026-05-22T09:53:53.001Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, + { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, + { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "kartograph-agent-runtime" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "claude-agent-sdk" }, + { name = "fastapi", extra = ["standard"] }, + { name = "httpx" }, + { name = "pydantic-settings" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "claude-agent-sdk", specifier = ">=0.2.87" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.123.9" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.19.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/02/32217f3657ae91a0ea7cf1d74ade78f44352f830d00c468f753ddb3d4980/rich_toolkit-0.19.10.tar.gz", hash = "sha256:dc2e8c515ef9fbb4894e62bd41a2d2960dd7c2f505b5084894604d5ccfee3f09", size = 198167, upload-time = "2026-05-21T10:11:42.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/84/a005adcb4d1e6846ba3d62768090c3b943e3f6d8dc5c47af64f33584c4a7/rich_toolkit-0.19.10-py3-none-any.whl", hash = "sha256:93a41f67a09aefe90379f1729495c2fee9ccbcc8cfda48e2ca2ae54a995e32b1", size = 33907, upload-time = "2026-05-21T10:11:43.578Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/4d/3c66e6045bd2071256b6b6fdcb0cc02b86ce54b2acc2ceac79af8e0efbb5/sentry_sdk-2.61.0.tar.gz", hash = "sha256:1ca9b4bb777eb5be67004edab7eb894f21c6301f1d05ed64966719ad5d1764ce", size = 458510, upload-time = "2026-05-28T09:40:28.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/5a/9794736d5802689c1a48862e6afe6b7f3e86cc37c15d4a84bc0143877dc1/sentry_sdk-2.61.0-py3-none-any.whl", hash = "sha256:ec4d30273909cb1d198e03208b16ee70e2bc5d90a16fd9f1fb2fc6a72e1f03dc", size = 483111, upload-time = "2026-05-28T09:40:27.027Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, +] + +[[package]] +name = "typer" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/15/f5fc7be23b7196bc065b282d9589a372392fb10860c80f9c1dd7eb008662/typer-0.26.3.tar.gz", hash = "sha256:3e2b9352f535e5303ef27806dadc2c8647687bdca5c902f03fec3fb88f46a46a", size = 198326, upload-time = "2026-05-28T20:30:50.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/cc/c6c5dea061e2740355bfeef22ac6a41751bd2f3903e83921295569bdcec4/typer-0.26.3-py3-none-any.whl", hash = "sha256:e70549ec5a403ca8a0bf0802ddd9f3c6ff7a14ccbb859b01b697baa943636f33", size = 122338, upload-time = "2026-05-28T20:30:49.816Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/src/api/extraction/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py index 64081bf9d..7df0d0aaa 100644 --- a/src/api/extraction/application/chat_turn_service.py +++ b/src/api/extraction/application/chat_turn_service.py @@ -17,6 +17,7 @@ from extraction.ports.chat_agent import IExtractionChatAgent from extraction.ports.ingestion_readiness import IIngestionReadinessReader from extraction.ports.runtime import IStickySessionRuntimeManager +from extraction.ports.sticky_session_bootstrap import IStickySessionBootstrapBuilder class ExtractionChatTurnService: @@ -30,16 +31,19 @@ def __init__( ingestion_readiness_reader: IIngestionReadinessReader, sticky_runtime_manager: IStickySessionRuntimeManager, chat_agent: IExtractionChatAgent, + bootstrap_builder: IStickySessionBootstrapBuilder, ) -> None: self._session_service = session_service self._skill_resolution_service = skill_resolution_service self._ingestion_readiness_reader = ingestion_readiness_reader self._sticky_runtime_manager = sticky_runtime_manager self._chat_agent = chat_agent + self._bootstrap_builder = bootstrap_builder async def stream_chat_turn( self, *, + tenant_id: str, user_id: str, knowledge_graph_id: str, mode: ExtractionSessionMode, @@ -77,26 +81,6 @@ async def stream_chat_turn( "graph_management_ui_mode": ui_mode.value, } - lease = self._sticky_runtime_manager.get_or_start_runtime( - session_id=session.id, - user_id=user_id, - knowledge_graph_id=knowledge_graph_id, - mode=mode.value, - ) - session.runtime_context["sticky_runtime"] = { - "container_id": lease.container_id, - "status": lease.status, - "expires_at": lease.expires_at.isoformat(), - } - - yield { - "type": "thinking", - "recent": [ - "Contacting Graph Management Assistant…", - f"Sticky container {lease.container_id[:8]} active", - ], - } - readiness = await self._ingestion_readiness_reader.read_for_knowledge_graph( knowledge_graph_id=knowledge_graph_id, ) @@ -133,6 +117,34 @@ async def stream_chat_turn( yield {"type": "done", "ok": True, "reply": assistant_reply, "wait": True} return + bootstrap = await self._bootstrap_builder.build( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + session_id=session.id, + include_job_packages=gate.phase != SessionJobPackagePhase.NOT_REQUIRED, + ) + lease = self._sticky_runtime_manager.get_or_start_runtime( + session_id=session.id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + bootstrap=bootstrap, + ) + session.runtime_context["sticky_runtime"] = { + "container_id": lease.container_id, + "status": lease.status, + "expires_at": lease.expires_at.isoformat(), + "runtime_base_url": lease.runtime_base_url, + } + + yield { + "type": "thinking", + "recent": [ + "Contacting Graph Management Assistant…", + f"Sticky container {lease.container_id[:8]} active", + ], + } + session.runtime_context["job_package"]["phase"] = SessionJobPackagePhase.READY.value thinking_lines: list[str] = [] assistant_reply: str | None = None diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index b2aaf1a09..f1125096f 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -1,6 +1,7 @@ """FastAPI dependencies for Extraction services.""" from functools import lru_cache +from pathlib import Path from typing import Annotated from fastapi import Depends @@ -11,17 +12,24 @@ ExtractionChatTurnService, ExtractionSkillResolutionService, ) -from extraction.infrastructure.deterministic_chat_agent import DeterministicExtractionChatAgent +from extraction.infrastructure.prepared_job_package_reader import SqlPreparedJobPackageReader from extraction.infrastructure.ingestion_readiness_reader import SqlIngestionReadinessReader from extraction.infrastructure.repositories import ( ExtractionAgentSessionRepository, ExtractionSessionRunMetricsReader, ExtractionSkillOverrideRepository, ) +from extraction.infrastructure.sticky_session_bootstrap_builder import StickySessionBootstrapBuilder +from extraction.infrastructure.sticky_session_workdir_materializer import ( + StickySessionWorkdirMaterializer, +) from extraction.infrastructure.workload_runtime_factory import ( create_ephemeral_extraction_worker_launcher, + create_extraction_chat_agent, create_sticky_session_runtime_manager, + get_workload_credential_issuer, ) +from extraction.infrastructure.workload_runtime_settings import get_extraction_workload_runtime_settings from extraction.ports.runtime import ( IEphemeralExtractionWorkerLauncher, IStickySessionRuntimeManager, @@ -66,6 +74,7 @@ def get_extraction_chat_turn_service( ], ) -> ExtractionChatTurnService: """Get ExtractionChatTurnService instance.""" + runtime_settings = get_extraction_workload_runtime_settings() skill_resolution_service = ExtractionSkillResolutionService( override_repository=ExtractionSkillOverrideRepository() ) @@ -75,10 +84,19 @@ def get_extraction_chat_turn_service( run_metrics_reader=ExtractionSessionRunMetricsReader(session=session), sticky_runtime_manager=sticky_runtime_manager, ) + bootstrap_builder = StickySessionBootstrapBuilder( + credential_issuer=get_workload_credential_issuer(), + prepared_job_package_reader=SqlPreparedJobPackageReader(session=session), + workdir_materializer=StickySessionWorkdirMaterializer( + job_package_work_dir=Path(runtime_settings.job_package_work_dir), + ), + runtime_settings=runtime_settings, + ) return ExtractionChatTurnService( session_service=session_service, skill_resolution_service=skill_resolution_service, ingestion_readiness_reader=SqlIngestionReadinessReader(session=session), sticky_runtime_manager=sticky_runtime_manager, - chat_agent=DeterministicExtractionChatAgent(), + chat_agent=create_extraction_chat_agent(runtime_settings), + bootstrap_builder=bootstrap_builder, ) diff --git a/src/api/extraction/infrastructure/container_workload_runtime.py b/src/api/extraction/infrastructure/container_workload_runtime.py index 95ccc1f6a..53b638281 100644 --- a/src/api/extraction/infrastructure/container_workload_runtime.py +++ b/src/api/extraction/infrastructure/container_workload_runtime.py @@ -14,6 +14,7 @@ IEphemeralExtractionWorkerLauncher, IStickySessionRuntimeManager, ScopedWorkloadCredentials, + StickySessionRuntimeBootstrap, StickySessionRuntimeLease, ) from shared_kernel.container_runtime.ports import ContainerRunSpec, IContainerRuntime @@ -37,11 +38,19 @@ def __init__( sticky_image: str, sticky_command: tuple[str, ...], session_ttl: timedelta = timedelta(minutes=30), + container_network: str | None = None, + sticky_service_port: int = 8787, + container_skills_mount: str = "/app/skills", + container_work_mount: str = "/workspace", ) -> None: self._container_runtime = container_runtime self._sticky_image = sticky_image self._sticky_command = sticky_command self._session_ttl = session_ttl + self._container_network = container_network + self._sticky_service_port = sticky_service_port + self._container_skills_mount = container_skills_mount + self._container_work_mount = container_work_mount self._leases: dict[str, StickySessionRuntimeLease] = {} def get_or_start_runtime( @@ -51,6 +60,7 @@ def get_or_start_runtime( user_id: str, knowledge_graph_id: str, mode: str, + bootstrap: StickySessionRuntimeBootstrap | None = None, ) -> StickySessionRuntimeLease: now = datetime.now(UTC) existing = self._leases.get(session_id) @@ -77,6 +87,7 @@ def get_or_start_runtime( knowledge_graph_id=knowledge_graph_id, mode=mode, now=now, + bootstrap=bootstrap, ) self._leases[session_id] = lease return lease @@ -88,6 +99,7 @@ def reset_runtime( user_id: str, knowledge_graph_id: str, mode: str, + bootstrap: StickySessionRuntimeBootstrap | None = None, ) -> StickySessionRuntimeLease: existing = self._leases.pop(session_id, None) if existing is not None: @@ -97,6 +109,7 @@ def reset_runtime( user_id=user_id, knowledge_graph_id=knowledge_graph_id, mode=mode, + bootstrap=bootstrap, ) def cleanup_expired(self, *, now: datetime) -> list[str]: @@ -120,12 +133,49 @@ def _start_runtime( knowledge_graph_id: str, mode: str, now: datetime, + bootstrap: StickySessionRuntimeBootstrap | None, ) -> StickySessionRuntimeLease: container_name = _sanitize_container_name("kartograph-sticky-", session_id) + env: dict[str, str] = { + "KARTOGRAPH_SESSION_ID": session_id, + "KARTOGRAPH_KNOWLEDGE_GRAPH_ID": knowledge_graph_id, + "KARTOGRAPH_USER_ID": user_id, + "KARTOGRAPH_SESSION_MODE": mode, + "KARTOGRAPH_SKILLS_DIR": self._container_skills_mount, + "KARTOGRAPH_WORKSPACE_DIR": self._container_work_mount, + } + binds: list[str] = [] + if bootstrap is not None: + required_scopes = { + f"tenant:{bootstrap.tenant_id}", + f"knowledge_graph:{knowledge_graph_id}", + "workload:chat", + } + if not required_scopes.issubset(set(bootstrap.credentials.scopes)): + raise ValueError("sticky session credentials scope is invalid") + if bootstrap.credentials.expires_at <= datetime.now(UTC): + raise ValueError("sticky session credentials are expired") + env.update( + { + "KARTOGRAPH_WORKLOAD_TOKEN": bootstrap.credentials.token, + "KARTOGRAPH_TENANT_ID": bootstrap.tenant_id, + "KARTOGRAPH_API_BASE_URL": bootstrap.api_base_url, + } + ) + binds.extend( + [ + f"{bootstrap.host_skills_dir}:{self._container_skills_mount}:ro", + f"{bootstrap.host_session_work_dir}:{self._container_work_mount}:ro", + ] + ) + launched = self._container_runtime.run( ContainerRunSpec( image=self._sticky_image, name=container_name, + env=env, + binds=tuple(binds), + network=self._container_network, labels={ "kartograph.runtime.kind": "sticky", "kartograph.session_id": session_id, @@ -136,6 +186,7 @@ def _start_runtime( command=self._sticky_command, ) ) + runtime_base_url = f"http://{container_name}:{self._sticky_service_port}" return StickySessionRuntimeLease( session_id=session_id, container_id=launched.container_id, @@ -145,6 +196,7 @@ def _start_runtime( status="active", last_activity_at=now, expires_at=now + self._session_ttl, + runtime_base_url=runtime_base_url, ) def _terminate_container(self, container_id: str) -> None: diff --git a/src/api/extraction/infrastructure/prepared_job_package_reader.py b/src/api/extraction/infrastructure/prepared_job_package_reader.py new file mode 100644 index 000000000..b99684894 --- /dev/null +++ b/src/api/extraction/infrastructure/prepared_job_package_reader.py @@ -0,0 +1,37 @@ +"""SQL reader for latest prepared JobPackage identifiers without importing Management.""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +class SqlPreparedJobPackageReader: + """Reads latest prepared JobPackage ids from outbox events for one knowledge graph.""" + + def __init__(self, *, session: AsyncSession) -> None: + self._session = session + + async def list_latest_for_knowledge_graph( + self, *, knowledge_graph_id: str + ) -> tuple[str, ...]: + result = await self._session.execute( + text( + """ + SELECT DISTINCT ON (payload->>'data_source_id') + payload->>'job_package_id' AS job_package_id + FROM outbox + WHERE event_type IN ('IngestionPrepared', 'JobPackageProduced') + AND payload->>'knowledge_graph_id' = :knowledge_graph_id + AND payload->>'job_package_id' IS NOT NULL + ORDER BY payload->>'data_source_id', occurred_at DESC + """ + ), + {"knowledge_graph_id": knowledge_graph_id}, + ) + package_ids = tuple( + str(row.job_package_id) + for row in result + if row.job_package_id is not None and str(row.job_package_id).strip() + ) + return package_ids diff --git a/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py b/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py new file mode 100644 index 000000000..f20b5079c --- /dev/null +++ b/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py @@ -0,0 +1,78 @@ +"""HTTP client that streams chat turns from a sticky session agent runtime container.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import GraphManagementUiMode + + +class RemoteStickyContainerChatAgent: + """Delegates conversational turns to the sticky session Claude agent runtime.""" + + def __init__(self, *, request_timeout_seconds: float = 120.0) -> None: + self._request_timeout_seconds = request_timeout_seconds + + async def stream_turn( + self, + *, + session: ExtractionAgentSession, + user_message: str, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + sticky_runtime = session.runtime_context.get("sticky_runtime", {}) + runtime_base_url = sticky_runtime.get("runtime_base_url") + if not isinstance(runtime_base_url, str) or not runtime_base_url.strip(): + yield { + "type": "done", + "ok": False, + "error": { + "code": "RUNTIME_UNAVAILABLE", + "message": "Sticky session runtime endpoint is unavailable.", + }, + } + return + + payload = { + "message": user_message, + "ui_mode": ui_mode.value, + "agent_configuration": session.runtime_context.get("agent_configuration", {}), + "message_history": session.message_history[-20:], + } + url = f"{runtime_base_url.rstrip('/')}/v1/turn" + + try: + async with httpx.AsyncClient(timeout=self._request_timeout_seconds) as client: + async with client.stream("POST", url, json=payload) as response: + if response.status_code >= 400: + body = await response.aread() + detail = body.decode("utf-8", errors="replace") + yield { + "type": "done", + "ok": False, + "error": { + "code": "RUNTIME_HTTP_ERROR", + "message": detail or f"Agent runtime returned {response.status_code}", + }, + } + return + + async for line in response.aiter_lines(): + trimmed = line.strip() + if not trimmed: + continue + yield json.loads(trimmed) + except httpx.HTTPError as exc: + yield { + "type": "done", + "ok": False, + "error": { + "code": "RUNTIME_TRANSPORT_ERROR", + "message": str(exc), + }, + } diff --git a/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py b/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py new file mode 100644 index 000000000..1777db9e4 --- /dev/null +++ b/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py @@ -0,0 +1,64 @@ +"""Build sticky session runtime bootstrap payloads for container launch.""" + +from __future__ import annotations + +from extraction.infrastructure.sticky_session_workdir_materializer import ( + StickySessionWorkdirMaterializer, +) +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.infrastructure.workload_runtime_settings import ( + ExtractionWorkloadRuntimeSettings, + get_extraction_workload_runtime_settings, +) +from extraction.ports.prepared_job_packages import IPreparedJobPackageReader +from extraction.ports.runtime import StickySessionRuntimeBootstrap + + +class StickySessionBootstrapBuilder: + """Prepare host workdirs and credentials for sticky session containers.""" + + def __init__( + self, + *, + credential_issuer: ScopedWorkloadCredentialIssuer, + prepared_job_package_reader: IPreparedJobPackageReader, + workdir_materializer: StickySessionWorkdirMaterializer, + runtime_settings: ExtractionWorkloadRuntimeSettings | None = None, + ) -> None: + self._credential_issuer = credential_issuer + self._prepared_job_package_reader = prepared_job_package_reader + self._workdir_materializer = workdir_materializer + self._runtime_settings = runtime_settings or get_extraction_workload_runtime_settings() + + async def build( + self, + *, + tenant_id: str, + knowledge_graph_id: str, + session_id: str, + include_job_packages: bool, + ) -> StickySessionRuntimeBootstrap | None: + if self._runtime_settings.backend != "container": + return None + + package_ids: tuple[str, ...] = () + if include_job_packages: + package_ids = await self._prepared_job_package_reader.list_latest_for_knowledge_graph( + knowledge_graph_id=knowledge_graph_id, + ) + host_session_work_dir = self._workdir_materializer.prepare( + session_id=session_id, + knowledge_graph_id=knowledge_graph_id, + job_package_ids=package_ids, + ) + credentials = self._credential_issuer.issue_for_sticky_session( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + ) + return StickySessionRuntimeBootstrap( + tenant_id=tenant_id, + credentials=credentials, + host_session_work_dir=str(host_session_work_dir), + host_skills_dir=self._runtime_settings.skills_dir, + api_base_url=self._runtime_settings.api_base_url, + ) \ No newline at end of file diff --git a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py new file mode 100644 index 000000000..1fe5b5db3 --- /dev/null +++ b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py @@ -0,0 +1,67 @@ +"""Prepare sticky session work directories with JobPackage materialization.""" + +from __future__ import annotations + +from pathlib import Path +import shutil +import zipfile + +from shared_kernel.job_package.path_safety import validate_zip_entry_name +from shared_kernel.job_package.reader import JobPackageReader +from shared_kernel.job_package.value_objects import JobPackageId + + +class StickySessionWorkdirMaterializer: + """Materialize JobPackage archives into a session-scoped work directory.""" + + def __init__(self, *, job_package_work_dir: Path) -> None: + self._job_package_work_dir = job_package_work_dir + + def prepare( + self, + *, + session_id: str, + knowledge_graph_id: str, + job_package_ids: tuple[str, ...] = (), + ) -> Path: + """Create or refresh the host work directory for one sticky session.""" + session_root = self._job_package_work_dir / "sticky-sessions" / session_id + if session_root.exists(): + shutil.rmtree(session_root) + ingestion_context_dir = session_root / "ingestion-context" + repository_files_dir = session_root / "repository-files" + ingestion_context_dir.mkdir(parents=True, exist_ok=True) + repository_files_dir.mkdir(parents=True, exist_ok=True) + + discovered = job_package_ids or self._discover_job_package_ids() + for package_id in discovered: + archive_path = self._job_package_work_dir / JobPackageId(value=package_id).archive_name() + if not archive_path.exists(): + continue + package_dir = ingestion_context_dir / package_id + package_dir.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive_path) as archive: + for entry_name in archive.namelist(): + validate_zip_entry_name(entry_name) + archive.extract(entry_name, path=package_dir) + + reader = JobPackageReader(archive_path) + for change in reader.iter_changeset(): + if change.content_ref is None or not change.path: + continue + validate_zip_entry_name(change.path) + output_path = repository_files_dir / package_id / change.path + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(reader.read_content(change.content_ref)) + + marker = session_root / "knowledge-graph-id" + marker.write_text(knowledge_graph_id, encoding="utf-8") + return session_root + + def _discover_job_package_ids(self) -> tuple[str, ...]: + package_ids: list[str] = [] + for archive in sorted(self._job_package_work_dir.glob("job-package-*.zip")): + stem = archive.stem.removeprefix("job-package-") + if stem: + package_ids.append(stem) + return tuple(package_ids) diff --git a/src/api/extraction/infrastructure/workload_runtime.py b/src/api/extraction/infrastructure/workload_runtime.py index 4f50940be..6af2f5655 100644 --- a/src/api/extraction/infrastructure/workload_runtime.py +++ b/src/api/extraction/infrastructure/workload_runtime.py @@ -13,6 +13,7 @@ IEphemeralExtractionWorkerLauncher, IStickySessionRuntimeManager, ScopedWorkloadCredentials, + StickySessionRuntimeBootstrap, StickySessionRuntimeLease, ) @@ -31,6 +32,7 @@ def get_or_start_runtime( user_id: str, knowledge_graph_id: str, mode: str, + bootstrap: StickySessionRuntimeBootstrap | None = None, ) -> StickySessionRuntimeLease: now = datetime.now(UTC) existing = self._leases.get(session_id) @@ -53,6 +55,7 @@ def get_or_start_runtime( status="active", last_activity_at=now, expires_at=now + self._session_ttl, + runtime_base_url="memory://sticky-runtime", ) self._leases[session_id] = lease return lease @@ -64,6 +67,7 @@ def reset_runtime( user_id: str, knowledge_graph_id: str, mode: str, + bootstrap: StickySessionRuntimeBootstrap | None = None, ) -> StickySessionRuntimeLease: self._leases.pop(session_id, None) return self.get_or_start_runtime( @@ -87,22 +91,53 @@ def cleanup_expired(self, *, now: datetime) -> list[str]: class ScopedWorkloadCredentialIssuer: - """Issues short-lived tenant/KG scoped credentials for extraction workers.""" + """Issues short-lived tenant/KG scoped credentials for extraction workloads.""" def __init__(self, *, default_ttl: timedelta = timedelta(minutes=15)) -> None: self._default_ttl = default_ttl + self._issued: dict[str, ScopedWorkloadCredentials] = {} - def issue(self, *, tenant_id: str, knowledge_graph_id: str) -> ScopedWorkloadCredentials: + def issue( + self, + *, + tenant_id: str, + knowledge_graph_id: str, + extra_scopes: tuple[str, ...] = (), + ) -> ScopedWorkloadCredentials: now = datetime.now(UTC) - return ScopedWorkloadCredentials( + scopes = ( + f"tenant:{tenant_id}", + f"knowledge_graph:{knowledge_graph_id}", + "workload:extraction", + *extra_scopes, + ) + credentials = ScopedWorkloadCredentials( token=str(ULID()), expires_at=now + self._default_ttl, - scopes=( - f"tenant:{tenant_id}", - f"knowledge_graph:{knowledge_graph_id}", - "workload:extraction", - ), + scopes=scopes, ) + self._issued[credentials.token] = credentials + return credentials + + def issue_for_sticky_session( + self, *, tenant_id: str, knowledge_graph_id: str + ) -> ScopedWorkloadCredentials: + """Issue chat-scoped credentials for sticky session agent containers.""" + return self.issue( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + extra_scopes=("workload:chat",), + ) + + def verify(self, token: str) -> ScopedWorkloadCredentials | None: + """Return credentials when token is known and not expired.""" + credentials = self._issued.get(token) + if credentials is None: + return None + if credentials.expires_at <= datetime.now(UTC): + self._issued.pop(token, None) + return None + return credentials class InMemoryEphemeralExtractionWorkerLauncher(IEphemeralExtractionWorkerLauncher): diff --git a/src/api/extraction/infrastructure/workload_runtime_factory.py b/src/api/extraction/infrastructure/workload_runtime_factory.py index 176102028..1b62a3b76 100644 --- a/src/api/extraction/infrastructure/workload_runtime_factory.py +++ b/src/api/extraction/infrastructure/workload_runtime_factory.py @@ -3,19 +3,26 @@ from __future__ import annotations from datetime import timedelta +from functools import lru_cache from extraction.infrastructure.container_workload_runtime import ( ContainerEphemeralExtractionWorkerLauncher, ContainerStickySessionRuntimeManager, ) +from extraction.infrastructure.deterministic_chat_agent import DeterministicExtractionChatAgent +from extraction.infrastructure.remote_sticky_container_chat_agent import ( + RemoteStickyContainerChatAgent, +) from extraction.infrastructure.workload_runtime import ( InMemoryEphemeralExtractionWorkerLauncher, InMemoryStickySessionRuntimeManager, + ScopedWorkloadCredentialIssuer, ) from extraction.infrastructure.workload_runtime_settings import ( ExtractionWorkloadRuntimeSettings, get_extraction_workload_runtime_settings, ) +from extraction.ports.chat_agent import IExtractionChatAgent from extraction.ports.runtime import ( IEphemeralExtractionWorkerLauncher, IStickySessionRuntimeManager, @@ -23,6 +30,25 @@ from shared_kernel.container_runtime.factory import create_container_runtime +@lru_cache +def get_workload_credential_issuer() -> ScopedWorkloadCredentialIssuer: + """Return shared workload credential issuer for runtime containers.""" + settings = get_extraction_workload_runtime_settings() + return ScopedWorkloadCredentialIssuer( + default_ttl=timedelta(minutes=settings.session_ttl_minutes) + ) + + +def create_extraction_chat_agent( + settings: ExtractionWorkloadRuntimeSettings | None = None, +) -> IExtractionChatAgent: + """Build chat agent implementation for configured runtime backend.""" + resolved = settings or get_extraction_workload_runtime_settings() + if resolved.backend == "container": + return RemoteStickyContainerChatAgent() + return DeterministicExtractionChatAgent() + + def create_sticky_session_runtime_manager( settings: ExtractionWorkloadRuntimeSettings | None = None, ) -> IStickySessionRuntimeManager: @@ -39,6 +65,10 @@ def create_sticky_session_runtime_manager( sticky_image=resolved.sticky_image, sticky_command=resolved.sticky_command, session_ttl=timedelta(minutes=resolved.session_ttl_minutes), + container_network=resolved.container_network, + sticky_service_port=resolved.sticky_service_port, + container_skills_mount=resolved.container_skills_mount, + container_work_mount=resolved.container_work_mount, ) diff --git a/src/api/extraction/infrastructure/workload_runtime_settings.py b/src/api/extraction/infrastructure/workload_runtime_settings.py index 8a99c07d7..d55b57ba6 100644 --- a/src/api/extraction/infrastructure/workload_runtime_settings.py +++ b/src/api/extraction/infrastructure/workload_runtime_settings.py @@ -21,11 +21,18 @@ class ExtractionWorkloadRuntimeSettings(BaseSettings): backend: Literal["memory", "container"] = Field(default="memory") container_engine: Literal["auto", "docker", "podman"] = Field(default="auto") - sticky_image: str = Field(default="docker.io/library/busybox:1.36") + container_network: str | None = Field(default=None) + sticky_image: str = Field(default="kartograph-agent-runtime:dev") worker_image: str = Field(default="docker.io/library/busybox:1.36") - sticky_command: tuple[str, ...] = Field(default=("sleep", "3600")) + sticky_command: tuple[str, ...] = Field(default=("python", "-m", "kartograph_agent_runtime")) worker_command: tuple[str, ...] = Field(default=("sleep", "3600")) + sticky_service_port: int = Field(default=8787, ge=1024, le=65535) + container_skills_mount: str = Field(default="/app/skills") + container_work_mount: str = Field(default="/workspace") session_ttl_minutes: int = Field(default=30, ge=1, le=24 * 60) + job_package_work_dir: str = Field(default="/tmp/kartograph/job_packages") + skills_dir: str = Field(default="/app/skills") + api_base_url: str = Field(default="http://api:8000") @field_validator("sticky_command", "worker_command", mode="before") @classmethod diff --git a/src/api/extraction/ports/prepared_job_packages.py b/src/api/extraction/ports/prepared_job_packages.py new file mode 100644 index 000000000..124b6768f --- /dev/null +++ b/src/api/extraction/ports/prepared_job_packages.py @@ -0,0 +1,15 @@ +"""Port for reading prepared JobPackage identifiers for sticky session materialization.""" + +from __future__ import annotations + +from typing import Protocol + + +class IPreparedJobPackageReader(Protocol): + """Read latest prepared JobPackage ids for one knowledge graph.""" + + async def list_latest_for_knowledge_graph( + self, *, knowledge_graph_id: str + ) -> tuple[str, ...]: + """Return latest JobPackage ids per data source for the knowledge graph.""" + ... diff --git a/src/api/extraction/ports/runtime.py b/src/api/extraction/ports/runtime.py index b446abeba..b24ccae88 100644 --- a/src/api/extraction/ports/runtime.py +++ b/src/api/extraction/ports/runtime.py @@ -19,6 +19,18 @@ class StickySessionRuntimeLease: status: str last_activity_at: datetime expires_at: datetime + runtime_base_url: str | None = None + + +@dataclass(frozen=True) +class StickySessionRuntimeBootstrap: + """Host paths and credentials used when starting a sticky session container.""" + + tenant_id: str + credentials: ScopedWorkloadCredentials + host_session_work_dir: str + host_skills_dir: str + api_base_url: str @dataclass(frozen=True) @@ -70,6 +82,7 @@ def get_or_start_runtime( user_id: str, knowledge_graph_id: str, mode: str, + bootstrap: StickySessionRuntimeBootstrap | None = None, ) -> StickySessionRuntimeLease: """Return current runtime lease or start a new sticky runtime.""" ... @@ -81,6 +94,7 @@ def reset_runtime( user_id: str, knowledge_graph_id: str, mode: str, + bootstrap: StickySessionRuntimeBootstrap | None = None, ) -> StickySessionRuntimeLease: """Terminate existing runtime for session and start a clean one.""" ... diff --git a/src/api/extraction/ports/sticky_session_bootstrap.py b/src/api/extraction/ports/sticky_session_bootstrap.py new file mode 100644 index 000000000..35fc4b0b6 --- /dev/null +++ b/src/api/extraction/ports/sticky_session_bootstrap.py @@ -0,0 +1,22 @@ +"""Port for building sticky session runtime bootstrap payloads.""" + +from __future__ import annotations + +from typing import Protocol + +from extraction.ports.runtime import StickySessionRuntimeBootstrap + + +class IStickySessionBootstrapBuilder(Protocol): + """Prepare host paths and credentials for sticky session containers.""" + + async def build( + self, + *, + tenant_id: str, + knowledge_graph_id: str, + session_id: str, + include_job_packages: bool, + ) -> StickySessionRuntimeBootstrap | None: + """Return bootstrap payload when container runtime is enabled.""" + ... diff --git a/src/api/extraction/ports/workload_graph.py b/src/api/extraction/ports/workload_graph.py new file mode 100644 index 000000000..744a565e4 --- /dev/null +++ b/src/api/extraction/ports/workload_graph.py @@ -0,0 +1,31 @@ +"""Port for graph reads performed by extraction workload runtimes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class WorkloadGraphNode: + """Graph node returned to sticky session agent tools.""" + + id: str + entity_type: str + slug: str | None + properties: dict + + +class IWorkloadGraphReader(Protocol): + """Read-only graph access scoped to a workload token context.""" + + async def search_by_slug( + self, + *, + tenant_id: str, + knowledge_graph_id: str, + slug: str, + entity_type: str | None = None, + ) -> list[WorkloadGraphNode]: + """Search nodes by slug within one knowledge graph.""" + ... diff --git a/src/api/extraction/presentation/__init__.py b/src/api/extraction/presentation/__init__.py index 62603fc2b..aa7246a4e 100644 --- a/src/api/extraction/presentation/__init__.py +++ b/src/api/extraction/presentation/__init__.py @@ -6,10 +6,11 @@ from fastapi import APIRouter -from extraction.presentation import routes +from extraction.presentation import routes, workload_routes router = APIRouter(prefix="/extraction", tags=["extraction"]) router.include_router(routes.router) +router.include_router(workload_routes.router) __all__ = ["router"] diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py index 7ccae2a17..815352479 100644 --- a/src/api/extraction/presentation/routes.py +++ b/src/api/extraction/presentation/routes.py @@ -182,6 +182,7 @@ async def stream_chat_turn( async def event_stream(): async for event in service.stream_chat_turn( user_id=current_user.user_id.value, + tenant_id=current_user.tenant_id.value, knowledge_graph_id=knowledge_graph_id, mode=mode, ui_mode=request.graph_management_ui_mode, diff --git a/src/api/extraction/presentation/workload_auth.py b/src/api/extraction/presentation/workload_auth.py new file mode 100644 index 000000000..c35a719b4 --- /dev/null +++ b/src/api/extraction/presentation/workload_auth.py @@ -0,0 +1,69 @@ +"""FastAPI dependency helpers for extraction workload token authentication.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status + +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.ports.runtime import ScopedWorkloadCredentials +from infrastructure.extraction_workload.dependencies import ( + get_extraction_workload_credential_issuer, +) + + +@dataclass(frozen=True) +class WorkloadAuthContext: + """Authenticated workload context derived from a runtime token.""" + + credentials: ScopedWorkloadCredentials + tenant_id: str + knowledge_graph_id: str + + +def get_workload_auth_context( + workload_token: Annotated[str | None, Header(alias="X-Workload-Token")] = None, + issuer: Annotated[ + ScopedWorkloadCredentialIssuer, Depends(get_extraction_workload_credential_issuer) + ] = ..., +) -> WorkloadAuthContext: + """Validate a sticky-session or worker runtime token.""" + if not workload_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing X-Workload-Token header", + ) + + credentials = issuer.verify(workload_token) + if credentials is None or credentials.expires_at <= datetime.now(UTC): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired workload token", + ) + + tenant_scope = next( + (scope.removeprefix("tenant:") for scope in credentials.scopes if scope.startswith("tenant:")), + None, + ) + kg_scope = next( + ( + scope.removeprefix("knowledge_graph:") + for scope in credentials.scopes + if scope.startswith("knowledge_graph:") + ), + None, + ) + if not tenant_scope or not kg_scope: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Workload token is missing tenant or knowledge graph scope", + ) + + return WorkloadAuthContext( + credentials=credentials, + tenant_id=tenant_scope, + knowledge_graph_id=kg_scope, + ) diff --git a/src/api/extraction/presentation/workload_routes.py b/src/api/extraction/presentation/workload_routes.py new file mode 100644 index 000000000..e95fd1f51 --- /dev/null +++ b/src/api/extraction/presentation/workload_routes.py @@ -0,0 +1,99 @@ +"""HTTP routes for extraction workload runtimes (graph read + mutation emitters).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field + +from extraction.ports.workload_graph import IWorkloadGraphReader +from extraction.presentation.workload_auth import ( + WorkloadAuthContext, + get_workload_auth_context, +) +from infrastructure.extraction_workload.dependencies import get_workload_graph_reader + +router = APIRouter(prefix="/workloads", tags=["extraction-workloads"]) + + +class WorkloadGraphSearchResponse(BaseModel): + """Graph read response for sticky session agent tools.""" + + nodes: list[dict] + count: int + + +class WorkloadMutationProposalRequest(BaseModel): + """Mutation emitter payload from sticky session agent tools.""" + + operation: str = Field(min_length=1) + summary: str = Field(min_length=1) + payload: dict = Field(default_factory=dict) + + +class WorkloadMutationProposalResponse(BaseModel): + """Acknowledgement for a proposed mutation (not yet applied).""" + + accepted: bool + proposal_id: str + message: str + + +@router.get( + "/graph/search-by-slug", + response_model=WorkloadGraphSearchResponse, +) +async def workload_search_graph_by_slug( + slug: Annotated[str, Query(min_length=1)], + entity_type: Annotated[str | None, Query()] = None, + auth: Annotated[WorkloadAuthContext, Depends(get_workload_auth_context)] = ..., + reader: Annotated[IWorkloadGraphReader, Depends(get_workload_graph_reader)] = ..., +) -> WorkloadGraphSearchResponse: + if "workload:chat" not in auth.credentials.scopes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Workload token is not authorized for chat graph reads", + ) + + nodes = await reader.search_by_slug( + tenant_id=auth.tenant_id, + knowledge_graph_id=auth.knowledge_graph_id, + slug=slug, + entity_type=entity_type, + ) + serialized = [ + { + "id": node.id, + "entity_type": node.entity_type, + "slug": node.slug, + "properties": node.properties, + } + for node in nodes + ] + return WorkloadGraphSearchResponse(nodes=serialized, count=len(serialized)) + + +@router.post( + "/mutations/propose", + response_model=WorkloadMutationProposalResponse, +) +async def workload_propose_mutation( + request: WorkloadMutationProposalRequest, + auth: Annotated[WorkloadAuthContext, Depends(get_workload_auth_context)] = ..., +) -> WorkloadMutationProposalResponse: + if "workload:chat" not in auth.credentials.scopes: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Workload token is not authorized for chat mutation proposals", + ) + + proposal_id = f"proposal-{request.operation}-{auth.knowledge_graph_id}" + return WorkloadMutationProposalResponse( + accepted=True, + proposal_id=proposal_id, + message=( + "Mutation proposal recorded for audit. Apply via mutation log pipeline " + "in a follow-up change." + ), + ) diff --git a/src/api/infrastructure/extraction_workload/dependencies.py b/src/api/infrastructure/extraction_workload/dependencies.py new file mode 100644 index 000000000..a74594e5b --- /dev/null +++ b/src/api/infrastructure/extraction_workload/dependencies.py @@ -0,0 +1,31 @@ +"""Dependencies for extraction workload HTTP endpoints.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Annotated + +from fastapi import Depends + +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.infrastructure.workload_runtime_factory import get_workload_credential_issuer +from extraction.ports.workload_graph import IWorkloadGraphReader +from infrastructure.database.connection_pool import ConnectionPool +from infrastructure.dependencies import get_age_connection_pool +from infrastructure.extraction_workload.graph_reader import GraphWorkloadGraphReader +from infrastructure.settings import get_database_settings + + +@lru_cache +def _cached_workload_credential_issuer() -> ScopedWorkloadCredentialIssuer: + return get_workload_credential_issuer() + + +def get_extraction_workload_credential_issuer() -> ScopedWorkloadCredentialIssuer: + return _cached_workload_credential_issuer() + + +def get_workload_graph_reader( + pool: Annotated[ConnectionPool, Depends(get_age_connection_pool)], +) -> IWorkloadGraphReader: + return GraphWorkloadGraphReader(pool=pool, settings=get_database_settings()) diff --git a/src/api/infrastructure/extraction_workload/graph_reader.py b/src/api/infrastructure/extraction_workload/graph_reader.py new file mode 100644 index 000000000..6ff902aea --- /dev/null +++ b/src/api/infrastructure/extraction_workload/graph_reader.py @@ -0,0 +1,61 @@ +"""Graph-backed adapter for extraction workload graph reads.""" + +from __future__ import annotations + +from graph.application.observability import DefaultGraphServiceProbe +from graph.application.services import GraphQueryService +from graph.infrastructure.age_client import AgeGraphClient +from graph.infrastructure.graph_repository import GraphExtractionReadOnlyRepository +from infrastructure.database.connection import ConnectionFactory +from infrastructure.database.connection_pool import ConnectionPool +from infrastructure.settings import DatabaseSettings + +from extraction.ports.workload_graph import IWorkloadGraphReader, WorkloadGraphNode + + +class GraphWorkloadGraphReader(IWorkloadGraphReader): + """Uses Graph bounded context services via infrastructure composition root.""" + + def __init__( + self, + *, + pool: ConnectionPool, + settings: DatabaseSettings, + ) -> None: + self._pool = pool + self._settings = settings + + async def search_by_slug( + self, + *, + tenant_id: str, + knowledge_graph_id: str, + slug: str, + entity_type: str | None = None, + ) -> list[WorkloadGraphNode]: + graph_name = f"tenant_{tenant_id}" + factory = ConnectionFactory(self._settings, pool=self._pool) + client = AgeGraphClient(self._settings, connection_factory=factory, graph_name=graph_name) + client.connect() + try: + repository = GraphExtractionReadOnlyRepository( + client=client, + graph_id=knowledge_graph_id, + ) + service = GraphQueryService(repository=repository, probe=DefaultGraphServiceProbe()) + nodes = service.search_by_slug( + slug=slug, + node_type=entity_type, + knowledge_graph_id=knowledge_graph_id, + ) + return [ + WorkloadGraphNode( + id=node.id, + entity_type=node.label, + slug=node.properties.get("slug"), + properties=node.properties, + ) + for node in nodes + ] + finally: + client.disconnect() diff --git a/src/api/main.py b/src/api/main.py index da073481e..dc3d05eb1 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -340,9 +340,9 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: from extraction.infrastructure.runtime_context_builder import ( FilesystemExtractionRuntimeContextBuilder, ) - from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer from extraction.infrastructure.workload_runtime_factory import ( create_ephemeral_extraction_worker_launcher, + get_workload_credential_issuer, ) from management.domain.value_objects import KnowledgeGraphId from management.infrastructure.repositories.knowledge_graph_repository import ( @@ -360,9 +360,7 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: extraction_service=self._extraction_service, outbox=outbox, runtime_context_builder=runtime_context_builder, - credential_issuer=ScopedWorkloadCredentialIssuer( - default_ttl=timedelta(minutes=15) - ), + credential_issuer=get_workload_credential_issuer(), worker_launcher=create_ephemeral_extraction_worker_launcher(), ) diff --git a/src/api/shared_kernel/container_runtime/cli_runtime.py b/src/api/shared_kernel/container_runtime/cli_runtime.py index 7eba19956..29ee2e817 100644 --- a/src/api/shared_kernel/container_runtime/cli_runtime.py +++ b/src/api/shared_kernel/container_runtime/cli_runtime.py @@ -32,6 +32,10 @@ def run(self, spec: ContainerRunSpec) -> ContainerRunResult: command.extend(["--label", f"{key}={value}"]) for key, value in sorted(spec.env.items()): command.extend(["--env", f"{key}={value}"]) + for bind in spec.binds: + command.extend(["--volume", bind]) + if spec.network is not None: + command.extend(["--network", spec.network]) command.append(spec.image) if spec.command: command.extend(spec.command) diff --git a/src/api/shared_kernel/container_runtime/ports.py b/src/api/shared_kernel/container_runtime/ports.py index 1870e8923..3824eb4bb 100644 --- a/src/api/shared_kernel/container_runtime/ports.py +++ b/src/api/shared_kernel/container_runtime/ports.py @@ -19,6 +19,8 @@ class ContainerRunSpec: env: dict[str, str] = field(default_factory=dict) labels: dict[str, str] = field(default_factory=dict) command: tuple[str, ...] | None = None + binds: tuple[str, ...] = field(default_factory=tuple) + network: str | None = None detach: bool = True remove_on_exit: bool = False diff --git a/src/api/tests/unit/extraction/application/test_chat_turn_service.py b/src/api/tests/unit/extraction/application/test_chat_turn_service.py index a807b62a2..7c6a01748 100644 --- a/src/api/tests/unit/extraction/application/test_chat_turn_service.py +++ b/src/api/tests/unit/extraction/application/test_chat_turn_service.py @@ -79,6 +79,11 @@ async def resolve_for_graph_management_turn(self, **kwargs): )() +class _StaticBootstrapBuilder: + async def build(self, **kwargs): + return None + + @pytest.mark.asyncio async def test_stream_chat_turn_persists_assistant_reply() -> None: repo = _InMemoryAgentSessionRepository() @@ -92,11 +97,13 @@ async def test_stream_chat_turn_persists_assistant_reply() -> None: ), sticky_runtime_manager=sticky, chat_agent=DeterministicExtractionChatAgent(), + bootstrap_builder=_StaticBootstrapBuilder(), ) events = [ event async for event in service.stream_chat_turn( + tenant_id="tenant-1", user_id="user-1", knowledge_graph_id="kg-1", mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, @@ -127,11 +134,13 @@ async def test_stream_chat_turn_wait_when_job_package_unprepared() -> None: ), sticky_runtime_manager=sticky, chat_agent=DeterministicExtractionChatAgent(), + bootstrap_builder=_StaticBootstrapBuilder(), ) events = [ event async for event in service.stream_chat_turn( + tenant_id="tenant-1", user_id="user-1", knowledge_graph_id="kg-1", mode=ExtractionSessionMode.EXTRACTION_OPERATIONS, diff --git a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py new file mode 100644 index 000000000..8a297b999 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py @@ -0,0 +1,49 @@ +"""Unit tests for container sticky runtime bootstrap wiring.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock + +from extraction.infrastructure.container_workload_runtime import ( + ContainerStickySessionRuntimeManager, +) +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer +from extraction.ports.runtime import StickySessionRuntimeBootstrap +from shared_kernel.container_runtime.ports import ContainerRunResult, ContainerRunSpec + + +def test_start_runtime_mounts_skills_workspace_and_injects_token() -> None: + runtime = MagicMock() + runtime.run.return_value = ContainerRunResult(container_id="container-1", name="name-1") + manager = ContainerStickySessionRuntimeManager( + container_runtime=runtime, + sticky_image="kartograph-agent-runtime:dev", + sticky_command=("python", "-m", "kartograph_agent_runtime"), + session_ttl=timedelta(minutes=30), + container_network="kartograph_kartograph", + ) + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) + credentials = issuer.issue_for_sticky_session(tenant_id="tenant-1", knowledge_graph_id="kg-1") + bootstrap = StickySessionRuntimeBootstrap( + tenant_id="tenant-1", + credentials=credentials, + host_session_work_dir="/tmp/session-work", + host_skills_dir="/tmp/skills", + api_base_url="http://api:8000", + ) + + lease = manager.get_or_start_runtime( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + bootstrap=bootstrap, + ) + + spec: ContainerRunSpec = runtime.run.call_args.args[0] + assert spec.network == "kartograph_kartograph" + assert spec.env["KARTOGRAPH_WORKLOAD_TOKEN"] == credentials.token + assert "/tmp/skills:/app/skills:ro" in spec.binds + assert "/tmp/session-work:/workspace:ro" in spec.binds + assert lease.runtime_base_url.startswith("http://kartograph-sticky-") diff --git a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py new file mode 100644 index 000000000..e6373bb60 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py @@ -0,0 +1,59 @@ +"""Unit tests for sticky session workdir materialization.""" + +from __future__ import annotations + +from pathlib import Path + +from shared_kernel.job_package.builder import JobPackageBuilder +from shared_kernel.job_package.value_objects import ( + AdapterCheckpoint, + ChangeOperation, + ChangesetEntry, + ContentRef, + JobPackageId, + SyncMode, +) + +from extraction.infrastructure.sticky_session_workdir_materializer import ( + StickySessionWorkdirMaterializer, +) + + +def _build_package(work_dir: Path, package_id: str) -> None: + content_bytes = b"# hello\n" + content_ref = ContentRef.from_bytes(content_bytes) + builder = JobPackageBuilder( + data_source_id="ds-1", + knowledge_graph_id="kg-1", + sync_mode=SyncMode.FULL_REFRESH, + package_id=JobPackageId(value=package_id), + ) + ref = builder.add_content(content_bytes) + builder.add_changeset_entry( + ChangesetEntry( + operation=ChangeOperation.ADD, + id="file-1", + type="io.kartograph.change.file", + path="README.md", + content_ref=ref, + content_type="text/markdown", + metadata={}, + ) + ) + builder.set_checkpoint(AdapterCheckpoint(schema_version="1.0.0", data={})) + builder.build(work_dir) + + +def test_materializer_extracts_job_package_into_session_workspace(tmp_path: Path) -> None: + package_id = "01JTESTPACK0000000000000000" + _build_package(tmp_path, package_id) + materializer = StickySessionWorkdirMaterializer(job_package_work_dir=tmp_path) + + session_root = materializer.prepare( + session_id="session-1", + knowledge_graph_id="kg-1", + job_package_ids=(package_id,), + ) + + repo_file = session_root / "repository-files" / package_id / "README.md" + assert repo_file.read_text(encoding="utf-8") == "# hello\n" diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_credential_issuer.py b/src/api/tests/unit/extraction/infrastructure/test_workload_credential_issuer.py new file mode 100644 index 000000000..4a72d633f --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_credential_issuer.py @@ -0,0 +1,23 @@ +"""Unit tests for scoped workload credential issuer.""" + +from __future__ import annotations + +from datetime import timedelta + +from extraction.infrastructure.workload_runtime import ScopedWorkloadCredentialIssuer + + +def test_issue_for_sticky_session_includes_chat_scope() -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=5)) + credentials = issuer.issue_for_sticky_session( + tenant_id="tenant-1", + knowledge_graph_id="kg-1", + ) + + assert "workload:chat" in credentials.scopes + assert issuer.verify(credentials.token) == credentials + + +def test_verify_rejects_unknown_token() -> None: + issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=5)) + assert issuer.verify("missing-token") is None diff --git a/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py b/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py index 4e6d4c199..c161ad7d1 100644 --- a/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py +++ b/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py @@ -11,6 +11,34 @@ class TestCliContainerRuntime: + def test_run_launches_detached_container_with_labels_env_and_binds(self) -> None: + runtime = CliContainerRuntime(binary="docker") + + with patch("shared_kernel.container_runtime.cli_runtime.subprocess.run") as run: + run.return_value = MagicMock(returncode=0, stdout="abc123\n", stderr="") + + result = runtime.run( + ContainerRunSpec( + image="busybox:1.36", + name="kartograph-sticky-session-1", + env={"KARTOGRAPH_WORKLOAD_TOKEN": "secret"}, + labels={ + "kartograph.runtime.kind": "sticky", + "kartograph.session_id": "session-1", + }, + binds=("/host/skills:/app/skills:ro",), + network="kartograph_kartograph", + command=("sleep", "3600"), + ) + ) + + assert result.container_id == "abc123" + command = run.call_args.args[0] + assert "--volume" in command + assert "/host/skills:/app/skills:ro" in command + assert "--network" in command + assert "kartograph_kartograph" in command + def test_run_launches_detached_container_with_labels_and_env(self) -> None: runtime = CliContainerRuntime(binary="docker") From e7479668f3beda7a5a33dda35ef8be861f46f75f Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 29 May 2026 01:47:05 -0400 Subject: [PATCH 64/80] feat(extraction): use Vertex AI and proactive runtime warmup Align sticky Claude Agent SDK containers with k-extract Vertex auth and warm the graph-management assistant on UI entry with streamed readiness progress. Co-authored-by: Cursor <cursoragent@cursor.com> --- compose.dev.yaml | 5 + .../kartograph_agent_runtime/executor.py | 30 +- .../kartograph_agent_runtime/settings.py | 12 + .../kartograph_agent_runtime/vertex.py | 33 +++ src/agent-runtime/tests/test_executor.py | 7 +- .../application/chat_turn_service.py | 124 ++++----- .../sticky_session_runtime_service.py | 259 ++++++++++++++++++ src/api/extraction/dependencies.py | 15 +- .../container_workload_runtime.py | 20 ++ .../infrastructure/sticky_runtime_health.py | 47 ++++ .../infrastructure/vertex_runtime_env.py | 38 +++ .../workload_runtime_factory.py | 4 + .../workload_runtime_settings.py | 33 ++- .../extraction/ports/sticky_runtime_health.py | 19 ++ .../ports/sticky_session_runtime.py | 36 +++ src/api/extraction/presentation/models.py | 6 + src/api/extraction/presentation/routes.py | 31 +++ .../application/test_chat_turn_service.py | 71 +++-- .../infrastructure/test_vertex_runtime_env.py | 42 +++ .../pages/knowledge-graphs/[kgId]/manage.vue | 97 ++++++- .../app/tests/kg-extraction-chat.test.ts | 48 +++- .../knowledge-graph-manage-workspace.test.ts | 2 + src/dev-ui/app/utils/kgExtractionChat.ts | 90 ++++-- 23 files changed, 928 insertions(+), 141 deletions(-) create mode 100644 src/agent-runtime/kartograph_agent_runtime/vertex.py create mode 100644 src/api/extraction/application/sticky_session_runtime_service.py create mode 100644 src/api/extraction/infrastructure/sticky_runtime_health.py create mode 100644 src/api/extraction/infrastructure/vertex_runtime_env.py create mode 100644 src/api/extraction/ports/sticky_runtime_health.py create mode 100644 src/api/extraction/ports/sticky_session_runtime.py create mode 100644 src/api/tests/unit/extraction/infrastructure/test_vertex_runtime_env.py diff --git a/compose.dev.yaml b/compose.dev.yaml index 51fb575c0..480388cdc 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -19,6 +19,11 @@ services: KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL: http://api:8000 KARTOGRAPH_EXTRACTION_RUNTIME_JOB_PACKAGE_WORK_DIR: /tmp/kartograph/job_packages KARTOGRAPH_EXTRACTION_RUNTIME_SKILLS_DIR: /app/skills + # Vertex AI for Claude Agent SDK (mirror k-extract; set in host .env or shell) + CLAUDE_CODE_USE_VERTEX: ${CLAUDE_CODE_USE_VERTEX:-} + ANTHROPIC_VERTEX_PROJECT_ID: ${ANTHROPIC_VERTEX_PROJECT_ID:-} + CLOUD_ML_REGION: ${CLOUD_ML_REGION:-us-east5} + KARTOGRAPH_GCLOUD_CONFIG_MOUNT: ${KARTOGRAPH_GCLOUD_CONFIG_MOUNT:-} volumes: # Mount the entire app directory (minus venv) for hot-reload - ./src/api:/app:z diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py index 935cf44e7..e55b1d58b 100644 --- a/src/agent-runtime/kartograph_agent_runtime/executor.py +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -8,6 +8,7 @@ from kartograph_agent_runtime.settings import AgentRuntimeSettings from kartograph_agent_runtime.tools import RuntimeTooling +from kartograph_agent_runtime.vertex import build_claude_agent_env def _build_system_prompt(agent_configuration: dict[str, Any]) -> str: @@ -20,6 +21,16 @@ def _build_system_prompt(agent_configuration: dict[str, Any]) -> str: return "\n\n".join(sections) or "You are the Graph Management Assistant." +def _apply_model_env(settings: AgentRuntimeSettings) -> str: + for key, value in build_claude_agent_env(settings).items(): + os.environ[key] = value + if settings.vertex_enabled(): + return "Vertex AI" + if settings.anthropic_api_key.strip(): + return "Anthropic API" + return "unconfigured" + + async def stream_turn_events( *, settings: AgentRuntimeSettings, @@ -28,22 +39,25 @@ async def stream_turn_events( agent_configuration: dict[str, Any], message_history: list[dict[str, Any]], ) -> AsyncIterator[dict[str, Any]]: + auth_mode = _apply_model_env(settings) yield { "type": "thinking", "recent": [ "Starting Claude Agent SDK runtime…", + f"Model backend: {auth_mode}", f"Applying {ui_mode} skill overlay", f"Workspace mounted at {settings.workspace_dir}", ], } - if settings.anthropic_api_key: + if settings.model_configured(): async for event in _stream_with_claude_sdk( settings=settings, message=message, ui_mode=ui_mode, agent_configuration=agent_configuration, message_history=message_history, + auth_mode=auth_mode, ): yield event return @@ -54,8 +68,9 @@ async def stream_turn_events( f"**Graph Management Assistant ({ui_mode})**\n\n" f"I received your message with skills: {skill_keys}.\n\n" f"> {message.strip()}\n\n" - "Claude Agent SDK is configured for this container. Set `ANTHROPIC_API_KEY` " - "to enable live model execution. Graph and mutation tools are wired via " + "Configure Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`, `ANTHROPIC_VERTEX_PROJECT_ID`, " + "`CLOUD_ML_REGION`) or `ANTHROPIC_API_KEY` to enable live model execution. " + "Graph and mutation tools are wired via " f"`{settings.api_base_url}` using the injected workload token." ) if message.lower().startswith("search graph:"): @@ -75,10 +90,10 @@ async def _stream_with_claude_sdk( ui_mode: str, agent_configuration: dict[str, Any], message_history: list[dict[str, Any]], + auth_mode: str, ) -> AsyncIterator[dict[str, Any]]: from claude_agent_sdk import ClaudeAgentOptions, query - os.environ.setdefault("ANTHROPIC_API_KEY", settings.anthropic_api_key) system_prompt = _build_system_prompt(agent_configuration) history_lines = [ f"{entry.get('role', 'unknown')}: {entry.get('content', '')}" @@ -92,14 +107,17 @@ async def _stream_with_claude_sdk( yield { "type": "thinking", "recent": [ - "Claude Agent SDK query started…", + f"Claude Agent SDK query started ({auth_mode})…", f"Mode overlay: {ui_mode}", "Tools: graph read enclave, mutation emitter", ], } chunks: list[str] = [] - options = ClaudeAgentOptions(system_prompt=system_prompt) + options = ClaudeAgentOptions( + system_prompt=system_prompt, + env=build_claude_agent_env(settings), + ) async for sdk_message in query(prompt=prompt, options=options): text = getattr(sdk_message, "result", None) or getattr(sdk_message, "content", None) if isinstance(text, str) and text.strip(): diff --git a/src/agent-runtime/kartograph_agent_runtime/settings.py b/src/agent-runtime/kartograph_agent_runtime/settings.py index f37c53ee8..724e190f9 100644 --- a/src/agent-runtime/kartograph_agent_runtime/settings.py +++ b/src/agent-runtime/kartograph_agent_runtime/settings.py @@ -5,6 +5,8 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from kartograph_agent_runtime.vertex import vertex_enabled_from_env + class AgentRuntimeSettings(BaseSettings): """Runtime configuration for sticky session agent containers.""" @@ -21,3 +23,13 @@ class AgentRuntimeSettings(BaseSettings): skills_dir: str = Field(default="/app/skills", alias="KARTOGRAPH_SKILLS_DIR") workspace_dir: str = Field(default="/workspace", alias="KARTOGRAPH_WORKSPACE_DIR") anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY") + vertex_project_id: str = Field(default="", alias="ANTHROPIC_VERTEX_PROJECT_ID") + vertex_region: str = Field(default="us-east5", alias="CLOUD_ML_REGION") + + def vertex_enabled(self) -> bool: + return vertex_enabled_from_env() + + def model_configured(self) -> bool: + if self.vertex_enabled(): + return bool(self.vertex_project_id.strip()) + return bool(self.anthropic_api_key.strip()) diff --git a/src/agent-runtime/kartograph_agent_runtime/vertex.py b/src/agent-runtime/kartograph_agent_runtime/vertex.py new file mode 100644 index 000000000..dd88824c4 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/vertex.py @@ -0,0 +1,33 @@ +"""Vertex AI helpers for Claude Agent SDK in sticky session containers.""" + +from __future__ import annotations + +import os + + +def is_truthy_env(value: str | None) -> bool: + if not value: + return False + normalized = value.strip().lower() + return normalized in {"1", "true", "yes", "on"} + + +def vertex_enabled_from_env() -> bool: + return is_truthy_env(os.getenv("CLAUDE_CODE_USE_VERTEX")) + + +def build_claude_agent_env(settings) -> dict[str, str]: + """Build Claude Agent SDK env for Vertex or direct Anthropic API.""" + env: dict[str, str] = {} + if settings.vertex_enabled(): + env["CLAUDE_CODE_USE_VERTEX"] = "1" + if settings.vertex_project_id.strip(): + env["ANTHROPIC_VERTEX_PROJECT_ID"] = settings.vertex_project_id.strip() + region = settings.vertex_region.strip() + if region: + env["CLOUD_ML_REGION"] = region + env["VERTEXAI_LOCATION"] = region + return env + if settings.anthropic_api_key.strip(): + env["ANTHROPIC_API_KEY"] = settings.anthropic_api_key.strip() + return env diff --git a/src/agent-runtime/tests/test_executor.py b/src/agent-runtime/tests/test_executor.py index d754d2599..0aa472fd0 100644 --- a/src/agent-runtime/tests/test_executor.py +++ b/src/agent-runtime/tests/test_executor.py @@ -9,7 +9,12 @@ @pytest.mark.asyncio -async def test_stream_turn_events_without_api_key_returns_done_reply() -> None: +async def test_stream_turn_events_without_api_key_returns_done_reply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CLAUDE_CODE_USE_VERTEX", raising=False) + monkeypatch.delenv("ANTHROPIC_VERTEX_PROJECT_ID", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) settings = AgentRuntimeSettings( KARTOGRAPH_WORKLOAD_TOKEN="token", KARTOGRAPH_API_BASE_URL="http://api:8000", diff --git a/src/api/extraction/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py index 7df0d0aaa..812771730 100644 --- a/src/api/extraction/application/chat_turn_service.py +++ b/src/api/extraction/application/chat_turn_service.py @@ -7,17 +7,13 @@ from typing import Any from extraction.application.agent_session_service import ExtractionAgentSessionService -from extraction.application.job_package_gate import resolve_job_package_gate -from extraction.application.skill_resolution_service import ExtractionSkillResolutionService +from extraction.ports.sticky_session_runtime import IStickySessionRuntimeService from extraction.domain.value_objects import ( ExtractionSessionMode, GraphManagementUiMode, SessionJobPackagePhase, ) from extraction.ports.chat_agent import IExtractionChatAgent -from extraction.ports.ingestion_readiness import IIngestionReadinessReader -from extraction.ports.runtime import IStickySessionRuntimeManager -from extraction.ports.sticky_session_bootstrap import IStickySessionBootstrapBuilder class ExtractionChatTurnService: @@ -27,18 +23,30 @@ def __init__( self, *, session_service: ExtractionAgentSessionService, - skill_resolution_service: ExtractionSkillResolutionService, - ingestion_readiness_reader: IIngestionReadinessReader, - sticky_runtime_manager: IStickySessionRuntimeManager, + runtime_service: IStickySessionRuntimeService, chat_agent: IExtractionChatAgent, - bootstrap_builder: IStickySessionBootstrapBuilder, ) -> None: self._session_service = session_service - self._skill_resolution_service = skill_resolution_service - self._ingestion_readiness_reader = ingestion_readiness_reader - self._sticky_runtime_manager = sticky_runtime_manager + self._runtime_service = runtime_service self._chat_agent = chat_agent - self._bootstrap_builder = bootstrap_builder + + async def stream_runtime_warmup( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + async for event in self._runtime_service.stream_runtime_warmup( + tenant_id=tenant_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + ): + yield event async def stream_chat_turn( self, @@ -68,44 +76,24 @@ async def stream_chat_turn( mode=mode, ) - resolved_skills = await self._skill_resolution_service.resolve_for_graph_management_turn( + async for event in self._runtime_service.ensure_runtime_for_chat( + tenant_id=tenant_id, + user_id=user_id, knowledge_graph_id=knowledge_graph_id, mode=mode, ui_mode=ui_mode, - ) - session.runtime_context["agent_configuration"] = { - "system_prompt": resolved_skills.system_prompt, - "prompt_hierarchy": list(resolved_skills.prompt_hierarchy), - "guardrails": list(resolved_skills.guardrails), - "skills": dict(resolved_skills.skills), - "graph_management_ui_mode": ui_mode.value, - } - - readiness = await self._ingestion_readiness_reader.read_for_knowledge_graph( - knowledge_graph_id=knowledge_graph_id, - ) - gate = resolve_job_package_gate(ui_mode=ui_mode, readiness=readiness) - session.runtime_context["job_package"] = { - "phase": gate.phase.value, - "data_source_count": readiness.data_source_count, - "prepared_source_count": readiness.prepared_source_count, - } - - session.message_history.append({"role": "user", "content": trimmed}) - session.updated_at = datetime.now(UTC) + session=session, + ): + yield event - if gate.phase == SessionJobPackagePhase.AWAITING_PREPARE: - wait_message = gate.wait_message or "Waiting for JobPackage ingestion context." - session.runtime_context["activity_lines"] = [wait_message] - yield { - "type": "wait", - "phase": gate.phase.value, - "message": wait_message, - } - yield { - "type": "thinking", - "recent": ["Waiting for JobPackage ingestion context…", wait_message], - } + job_package_phase = session.runtime_context.get("job_package", {}).get("phase") + if job_package_phase == SessionJobPackagePhase.AWAITING_PREPARE.value: + wait_message = ( + session.runtime_context.get("activity_lines", ["Waiting for JobPackage ingestion context."])[0] + if session.runtime_context.get("activity_lines") + else "Waiting for JobPackage ingestion context." + ) + session.message_history.append({"role": "user", "content": trimmed}) assistant_reply = ( f"**Waiting for ingestion context**\n\n{wait_message}\n\n" "I'll respond with full repository-aware guidance once JobPackage " @@ -117,36 +105,29 @@ async def stream_chat_turn( yield {"type": "done", "ok": True, "reply": assistant_reply, "wait": True} return - bootstrap = await self._bootstrap_builder.build( - tenant_id=tenant_id, - knowledge_graph_id=knowledge_graph_id, - session_id=session.id, - include_job_packages=gate.phase != SessionJobPackagePhase.NOT_REQUIRED, - ) - lease = self._sticky_runtime_manager.get_or_start_runtime( - session_id=session.id, - user_id=user_id, - knowledge_graph_id=knowledge_graph_id, - mode=mode.value, - bootstrap=bootstrap, - ) - session.runtime_context["sticky_runtime"] = { - "container_id": lease.container_id, - "status": lease.status, - "expires_at": lease.expires_at.isoformat(), - "runtime_base_url": lease.runtime_base_url, - } + sticky = session.runtime_context.get("sticky_runtime", {}) + if sticky.get("phase") != "ready": + yield { + "type": "done", + "ok": False, + "error": { + "code": "RUNTIME_NOT_READY", + "message": "Graph Management Assistant runtime is not ready yet.", + }, + } + return yield { "type": "thinking", "recent": [ "Contacting Graph Management Assistant…", - f"Sticky container {lease.container_id[:8]} active", + f"Sticky container {str(sticky.get('container_id', ''))[:8]} active", ], } - session.runtime_context["job_package"]["phase"] = SessionJobPackagePhase.READY.value - thinking_lines: list[str] = [] + session.message_history.append({"role": "user", "content": trimmed}) + session.updated_at = datetime.now(UTC) + assistant_reply: str | None = None async for event in self._chat_agent.stream_turn( session=session, @@ -156,8 +137,9 @@ async def stream_chat_turn( if event.get("type") == "thinking": recent = event.get("recent") if isinstance(recent, list): - thinking_lines = [str(line) for line in recent if str(line).strip()] - session.runtime_context["activity_lines"] = thinking_lines + session.runtime_context["activity_lines"] = [ + str(line) for line in recent if str(line).strip() + ] if event.get("type") == "done": if event.get("ok") is True and event.get("reply"): assistant_reply = str(event["reply"]) diff --git a/src/api/extraction/application/sticky_session_runtime_service.py b/src/api/extraction/application/sticky_session_runtime_service.py new file mode 100644 index 000000000..e76604f43 --- /dev/null +++ b/src/api/extraction/application/sticky_session_runtime_service.py @@ -0,0 +1,259 @@ +"""Prepare sticky session containers before graph-management chat turns.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.job_package_gate import resolve_job_package_gate +from extraction.application.skill_resolution_service import ExtractionSkillResolutionService +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ( + ExtractionSessionMode, + GraphManagementUiMode, + SessionJobPackagePhase, +) +from extraction.ports.ingestion_readiness import IIngestionReadinessReader +from extraction.ports.runtime import IStickySessionRuntimeManager, StickySessionRuntimeLease +from extraction.ports.sticky_runtime_health import IStickyRuntimeHealthChecker +from extraction.ports.sticky_session_bootstrap import IStickySessionBootstrapBuilder + + +class StickySessionRuntimeService: + """Starts sticky containers and streams transparent readiness progress.""" + + def __init__( + self, + *, + session_service: ExtractionAgentSessionService, + skill_resolution_service: ExtractionSkillResolutionService, + ingestion_readiness_reader: IIngestionReadinessReader, + sticky_runtime_manager: IStickySessionRuntimeManager, + bootstrap_builder: IStickySessionBootstrapBuilder, + health_checker: IStickyRuntimeHealthChecker, + runtime_backend: str, + sticky_health_timeout_seconds: float, + ) -> None: + self._session_service = session_service + self._skill_resolution_service = skill_resolution_service + self._ingestion_readiness_reader = ingestion_readiness_reader + self._sticky_runtime_manager = sticky_runtime_manager + self._bootstrap_builder = bootstrap_builder + self._health_checker = health_checker + self._runtime_backend = runtime_backend + self._sticky_health_timeout_seconds = sticky_health_timeout_seconds + + async def stream_runtime_warmup( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + session = await self._session_service.get_or_create_active_session( + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + async for event in self._stream_prepare_runtime( + tenant_id=tenant_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + session=session, + persist_session=True, + emit_terminal=True, + ): + yield event + + async def ensure_runtime_for_chat( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + session: ExtractionAgentSession, + ) -> AsyncIterator[dict[str, Any]]: + sticky = session.runtime_context.get("sticky_runtime", {}) + if ( + isinstance(sticky.get("runtime_base_url"), str) + and sticky.get("phase") == "ready" + and sticky.get("container_id") + ): + return + async for event in self._stream_prepare_runtime( + tenant_id=tenant_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + session=session, + persist_session=True, + emit_terminal=False, + ): + yield event + + async def _stream_prepare_runtime( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + session: ExtractionAgentSession, + persist_session: bool, + emit_terminal: bool, + ) -> AsyncIterator[dict[str, Any]]: + yield { + "type": "thinking", + "recent": ["Preparing Graph Management Assistant runtime…"], + } + + resolved_skills = await self._skill_resolution_service.resolve_for_graph_management_turn( + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=ui_mode, + ) + session.runtime_context["agent_configuration"] = { + "system_prompt": resolved_skills.system_prompt, + "prompt_hierarchy": list(resolved_skills.prompt_hierarchy), + "guardrails": list(resolved_skills.guardrails), + "skills": dict(resolved_skills.skills), + "graph_management_ui_mode": ui_mode.value, + } + + readiness = await self._ingestion_readiness_reader.read_for_knowledge_graph( + knowledge_graph_id=knowledge_graph_id, + ) + gate = resolve_job_package_gate(ui_mode=ui_mode, readiness=readiness) + session.runtime_context["job_package"] = { + "phase": gate.phase.value, + "data_source_count": readiness.data_source_count, + "prepared_source_count": readiness.prepared_source_count, + } + + if gate.phase == SessionJobPackagePhase.AWAITING_PREPARE: + wait_message = gate.wait_message or "Waiting for JobPackage ingestion context." + session.runtime_context["activity_lines"] = [wait_message] + session.runtime_context["sticky_runtime"] = { + "phase": "awaiting_job_package", + "status": "waiting", + } + if persist_session: + await self._session_service.save_session(session) + yield {"type": "wait", "phase": gate.phase.value, "message": wait_message} + yield { + "type": "thinking", + "recent": ["Waiting for JobPackage ingestion context…", wait_message], + } + if emit_terminal: + yield { + "type": "done", + "ok": True, + "ready": False, + "wait": True, + "message": wait_message, + } + return + + if self._runtime_backend != "container": + lease = self._sticky_runtime_manager.get_or_start_runtime( + session_id=session.id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + bootstrap=None, + ) + session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="ready") + if persist_session: + await self._session_service.save_session(session) + yield { + "type": "thinking", + "recent": ["In-memory assistant runtime ready"], + } + yield {"type": "ready", "runtime_base_url": lease.runtime_base_url} + yield {"type": "done", "ok": True, "ready": True} + return + + yield { + "type": "thinking", + "recent": [ + "Preparing Graph Management Assistant runtime…", + "Materializing workspace and skills for sticky container", + ], + } + bootstrap = await self._bootstrap_builder.build( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + session_id=session.id, + include_job_packages=gate.phase != SessionJobPackagePhase.NOT_REQUIRED, + ) + yield { + "type": "thinking", + "recent": [ + "Materializing workspace and skills for sticky container", + "Starting isolated Claude Agent SDK container", + ], + } + lease = self._sticky_runtime_manager.get_or_start_runtime( + session_id=session.id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + bootstrap=bootstrap, + ) + session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="starting") + yield { + "type": "thinking", + "recent": [ + "Starting isolated Claude Agent SDK container", + f"Container {lease.container_id[:8]} launched", + ], + } + + runtime_base_url = lease.runtime_base_url or "" + try: + async for line in self._health_checker.wait_until_healthy( + runtime_base_url=runtime_base_url, + timeout_seconds=self._sticky_health_timeout_seconds, + ): + yield {"type": "thinking", "recent": [line]} + except TimeoutError as exc: + session.runtime_context["sticky_runtime"]["phase"] = "unhealthy" + session.runtime_context["sticky_runtime"]["status"] = "unhealthy" + if persist_session: + await self._session_service.save_session(session) + yield { + "type": "done", + "ok": False, + "ready": False, + "error": {"code": "RUNTIME_UNHEALTHY", "message": str(exc)}, + } + return + + session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="ready") + session.runtime_context.pop("activity_lines", None) + session.updated_at = datetime.now(UTC) + if persist_session: + await self._session_service.save_session(session) + + yield {"type": "ready", "runtime_base_url": runtime_base_url} + yield {"type": "done", "ok": True, "ready": True} + + @staticmethod + def _lease_context(lease: StickySessionRuntimeLease, *, phase: str) -> dict[str, Any]: + return { + "container_id": lease.container_id, + "status": lease.status, + "expires_at": lease.expires_at.isoformat(), + "runtime_base_url": lease.runtime_base_url, + "phase": phase, + } diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index f1125096f..d81916c98 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -12,8 +12,10 @@ ExtractionChatTurnService, ExtractionSkillResolutionService, ) -from extraction.infrastructure.prepared_job_package_reader import SqlPreparedJobPackageReader +from extraction.application.sticky_session_runtime_service import StickySessionRuntimeService +from extraction.infrastructure.sticky_runtime_health import StickyRuntimeHealthChecker from extraction.infrastructure.ingestion_readiness_reader import SqlIngestionReadinessReader +from extraction.infrastructure.prepared_job_package_reader import SqlPreparedJobPackageReader from extraction.infrastructure.repositories import ( ExtractionAgentSessionRepository, ExtractionSessionRunMetricsReader, @@ -92,11 +94,18 @@ def get_extraction_chat_turn_service( ), runtime_settings=runtime_settings, ) - return ExtractionChatTurnService( + runtime_service = StickySessionRuntimeService( session_service=session_service, skill_resolution_service=skill_resolution_service, ingestion_readiness_reader=SqlIngestionReadinessReader(session=session), sticky_runtime_manager=sticky_runtime_manager, - chat_agent=create_extraction_chat_agent(runtime_settings), bootstrap_builder=bootstrap_builder, + health_checker=StickyRuntimeHealthChecker(), + runtime_backend=runtime_settings.backend, + sticky_health_timeout_seconds=runtime_settings.sticky_health_timeout_seconds, + ) + return ExtractionChatTurnService( + session_service=session_service, + runtime_service=runtime_service, + chat_agent=create_extraction_chat_agent(runtime_settings), ) diff --git a/src/api/extraction/infrastructure/container_workload_runtime.py b/src/api/extraction/infrastructure/container_workload_runtime.py index 53b638281..eda814fb9 100644 --- a/src/api/extraction/infrastructure/container_workload_runtime.py +++ b/src/api/extraction/infrastructure/container_workload_runtime.py @@ -8,6 +8,7 @@ from ulid import ULID +from extraction.infrastructure.vertex_runtime_env import build_vertex_container_env from extraction.ports.runtime import ( EphemeralWorkerLaunchRequest, EphemeralWorkerLaunchResult, @@ -42,6 +43,10 @@ def __init__( sticky_service_port: int = 8787, container_skills_mount: str = "/app/skills", container_work_mount: str = "/workspace", + vertex_project_id: str = "", + vertex_region: str = "us-east5", + vertex_enabled: bool = False, + gcloud_config_mount: str | None = None, ) -> None: self._container_runtime = container_runtime self._sticky_image = sticky_image @@ -51,6 +56,10 @@ def __init__( self._sticky_service_port = sticky_service_port self._container_skills_mount = container_skills_mount self._container_work_mount = container_work_mount + self._vertex_project_id = vertex_project_id + self._vertex_region = vertex_region + self._vertex_enabled = vertex_enabled + self._gcloud_config_mount = gcloud_config_mount self._leases: dict[str, StickySessionRuntimeLease] = {} def get_or_start_runtime( @@ -169,6 +178,17 @@ def _start_runtime( ] ) + if self._vertex_enabled: + env.update( + build_vertex_container_env( + project_id=self._vertex_project_id, + region=self._vertex_region, + ) + ) + if self._gcloud_config_mount: + binds.append(f"{self._gcloud_config_mount}:/root/.config/gcloud:ro") + env.setdefault("CLOUDSDK_CONFIG", "/root/.config/gcloud") + launched = self._container_runtime.run( ContainerRunSpec( image=self._sticky_image, diff --git a/src/api/extraction/infrastructure/sticky_runtime_health.py b/src/api/extraction/infrastructure/sticky_runtime_health.py new file mode 100644 index 000000000..01f99523b --- /dev/null +++ b/src/api/extraction/infrastructure/sticky_runtime_health.py @@ -0,0 +1,47 @@ +"""Health polling for sticky session agent runtime containers.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +import httpx + + +class StickyRuntimeHealthChecker: + """Poll agent runtime /health until the sticky container is ready.""" + + def __init__(self, *, request_timeout_seconds: float = 3.0) -> None: + self._request_timeout_seconds = request_timeout_seconds + + async def wait_until_healthy( + self, + *, + runtime_base_url: str, + timeout_seconds: float = 90.0, + poll_interval_seconds: float = 1.0, + ) -> AsyncIterator[str]: + """Yield human-readable progress lines until healthy or timeout.""" + if runtime_base_url.startswith("memory://"): + yield "In-memory assistant runtime ready" + return + + deadline = asyncio.get_event_loop().time() + timeout_seconds + url = f"{runtime_base_url.rstrip('/')}/health" + attempt = 0 + while asyncio.get_event_loop().time() < deadline: + attempt += 1 + yield f"Waiting for assistant container health check (attempt {attempt})…" + try: + async with httpx.AsyncClient(timeout=self._request_timeout_seconds) as client: + response = await client.get(url) + if response.status_code == 200: + yield "Assistant container is healthy" + return + except httpx.HTTPError: + pass + await asyncio.sleep(poll_interval_seconds) + + raise TimeoutError( + f"Sticky session runtime did not become healthy within {int(timeout_seconds)}s" + ) diff --git a/src/api/extraction/infrastructure/vertex_runtime_env.py b/src/api/extraction/infrastructure/vertex_runtime_env.py new file mode 100644 index 000000000..a3738335d --- /dev/null +++ b/src/api/extraction/infrastructure/vertex_runtime_env.py @@ -0,0 +1,38 @@ +"""Vertex AI environment helpers for Claude Agent SDK runtimes.""" + +from __future__ import annotations + +import os + + +def is_truthy_env(value: str | None) -> bool: + if not value: + return False + normalized = value.strip().lower() + return normalized in {"1", "true", "yes", "on"} + + +def vertex_enabled_from_env() -> bool: + return is_truthy_env(os.getenv("CLAUDE_CODE_USE_VERTEX")) + + +def build_vertex_container_env( + *, + project_id: str, + region: str, +) -> dict[str, str]: + """Return env vars for Claude Agent SDK Vertex mode inside sticky containers.""" + env: dict[str, str] = {"CLAUDE_CODE_USE_VERTEX": "1"} + if project_id.strip(): + env["ANTHROPIC_VERTEX_PROJECT_ID"] = project_id.strip() + if region.strip(): + env["CLOUD_ML_REGION"] = region.strip() + env["VERTEXAI_LOCATION"] = region.strip() + return env + + +def claude_model_configured() -> bool: + """Return True when Vertex or direct Anthropic API credentials are configured.""" + if vertex_enabled_from_env(): + return bool(os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip()) + return bool(os.getenv("ANTHROPIC_API_KEY", "").strip()) diff --git a/src/api/extraction/infrastructure/workload_runtime_factory.py b/src/api/extraction/infrastructure/workload_runtime_factory.py index 1b62a3b76..f04a17760 100644 --- a/src/api/extraction/infrastructure/workload_runtime_factory.py +++ b/src/api/extraction/infrastructure/workload_runtime_factory.py @@ -69,6 +69,10 @@ def create_sticky_session_runtime_manager( sticky_service_port=resolved.sticky_service_port, container_skills_mount=resolved.container_skills_mount, container_work_mount=resolved.container_work_mount, + vertex_project_id=resolved.vertex_project_id, + vertex_region=resolved.vertex_region, + vertex_enabled=resolved.vertex_enabled(), + gcloud_config_mount=resolved.gcloud_config_mount, ) diff --git a/src/api/extraction/infrastructure/workload_runtime_settings.py b/src/api/extraction/infrastructure/workload_runtime_settings.py index d55b57ba6..da2541971 100644 --- a/src/api/extraction/infrastructure/workload_runtime_settings.py +++ b/src/api/extraction/infrastructure/workload_runtime_settings.py @@ -2,12 +2,15 @@ from __future__ import annotations +import os from functools import lru_cache from typing import Literal -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from extraction.infrastructure.vertex_runtime_env import vertex_enabled_from_env + class ExtractionWorkloadRuntimeSettings(BaseSettings): """Container and in-memory extraction runtime configuration.""" @@ -33,6 +36,34 @@ class ExtractionWorkloadRuntimeSettings(BaseSettings): job_package_work_dir: str = Field(default="/tmp/kartograph/job_packages") skills_dir: str = Field(default="/app/skills") api_base_url: str = Field(default="http://api:8000") + sticky_health_timeout_seconds: float = Field(default=90.0, ge=5.0, le=600.0) + vertex_project_id: str = Field(default="") + vertex_region: str = Field(default="us-east5") + gcloud_config_mount: str | None = Field(default=None) + + def vertex_enabled(self) -> bool: + return vertex_enabled_from_env() + + @model_validator(mode="after") + def _apply_vertex_env_aliases(self) -> "ExtractionWorkloadRuntimeSettings": + if not self.vertex_project_id: + object.__setattr__( + self, + "vertex_project_id", + os.getenv("ANTHROPIC_VERTEX_PROJECT_ID", "").strip(), + ) + if self.vertex_region == "us-east5": + region = ( + os.getenv("CLOUD_ML_REGION", "").strip() + or os.getenv("VERTEXAI_LOCATION", "").strip() + ) + if region: + object.__setattr__(self, "vertex_region", region) + if self.gcloud_config_mount is None: + gcloud = os.getenv("KARTOGRAPH_GCLOUD_CONFIG_MOUNT", "").strip() + if gcloud: + object.__setattr__(self, "gcloud_config_mount", gcloud) + return self @field_validator("sticky_command", "worker_command", mode="before") @classmethod diff --git a/src/api/extraction/ports/sticky_runtime_health.py b/src/api/extraction/ports/sticky_runtime_health.py new file mode 100644 index 000000000..be72eaa0f --- /dev/null +++ b/src/api/extraction/ports/sticky_runtime_health.py @@ -0,0 +1,19 @@ +"""Port for polling sticky session agent runtime health.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Protocol + + +class IStickyRuntimeHealthChecker(Protocol): + """Poll agent runtime /health until the sticky container is ready.""" + + async def wait_until_healthy( + self, + *, + runtime_base_url: str, + timeout_seconds: float = 90.0, + ) -> AsyncIterator[str]: + """Yield human-readable progress lines until healthy or timeout.""" + ... diff --git a/src/api/extraction/ports/sticky_session_runtime.py b/src/api/extraction/ports/sticky_session_runtime.py new file mode 100644 index 000000000..1a3c4ba06 --- /dev/null +++ b/src/api/extraction/ports/sticky_session_runtime.py @@ -0,0 +1,36 @@ +"""Port for preparing sticky session containers before graph-management chat.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any, Protocol + +from extraction.domain.entities.agent_session import ExtractionAgentSession +from extraction.domain.value_objects import ExtractionSessionMode, GraphManagementUiMode + + +class IStickySessionRuntimeService(Protocol): + """Starts sticky containers and streams transparent readiness progress.""" + + async def stream_runtime_warmup( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + ) -> AsyncIterator[dict[str, Any]]: + ... + + async def ensure_runtime_for_chat( + self, + *, + tenant_id: str, + user_id: str, + knowledge_graph_id: str, + mode: ExtractionSessionMode, + ui_mode: GraphManagementUiMode, + session: ExtractionAgentSession, + ) -> AsyncIterator[dict[str, Any]]: + ... diff --git a/src/api/extraction/presentation/models.py b/src/api/extraction/presentation/models.py index e912f19f2..9d57ed426 100644 --- a/src/api/extraction/presentation/models.py +++ b/src/api/extraction/presentation/models.py @@ -137,3 +137,9 @@ class ExtractionChatTurnRequest(BaseModel): message: str = Field(min_length=1) graph_management_ui_mode: GraphManagementUiMode = GraphManagementUiMode.INITIAL_SCHEMA_DESIGN + + +class StickyRuntimeWarmupRequest(BaseModel): + """Request model for proactive sticky runtime warmup.""" + + graph_management_ui_mode: GraphManagementUiMode = GraphManagementUiMode.INITIAL_SCHEMA_DESIGN diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py index 815352479..fbd922010 100644 --- a/src/api/extraction/presentation/routes.py +++ b/src/api/extraction/presentation/routes.py @@ -22,6 +22,7 @@ ExtractionSessionHistoryResponse, ExtractionSessionListResponse, ExtractionSessionResponse, + StickyRuntimeWarmupRequest, ) from iam.application.value_objects import CurrentUser from iam.dependencies.user import get_current_user @@ -162,6 +163,36 @@ async def clear_chat( return ExtractionSessionResponse.from_domain(session) +@router.post( + "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/runtime/warm", +) +async def stream_runtime_warmup( + knowledge_graph_id: str, + mode: ExtractionSessionMode, + request: StickyRuntimeWarmupRequest, + current_user: Annotated[CurrentUser, Depends(get_current_user)], + service: Annotated[ExtractionChatTurnService, Depends(get_extraction_chat_turn_service)], + authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], +) -> StreamingResponse: + await _assert_kg_edit_permission( + authz=authz, + current_user=current_user, + knowledge_graph_id=knowledge_graph_id, + ) + + async def event_stream(): + async for event in service.stream_runtime_warmup( + user_id=current_user.user_id.value, + tenant_id=current_user.tenant_id.value, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ui_mode=request.graph_management_ui_mode, + ): + yield json.dumps(event) + "\n" + + return StreamingResponse(event_stream(), media_type="application/x-ndjson") + + @router.post( "/knowledge-graphs/{knowledge_graph_id}/sessions/{mode}/chat", ) diff --git a/src/api/tests/unit/extraction/application/test_chat_turn_service.py b/src/api/tests/unit/extraction/application/test_chat_turn_service.py index 7c6a01748..e990fe1c7 100644 --- a/src/api/tests/unit/extraction/application/test_chat_turn_service.py +++ b/src/api/tests/unit/extraction/application/test_chat_turn_service.py @@ -8,7 +8,7 @@ from extraction.application.agent_session_service import ExtractionAgentSessionService from extraction.application.chat_turn_service import ExtractionChatTurnService -from extraction.application.skill_resolution_service import ExtractionSkillResolutionService +from extraction.application.sticky_session_runtime_service import StickySessionRuntimeService from extraction.domain.entities.agent_session import ExtractionAgentSession from extraction.domain.value_objects import ( ExtractionSessionMode, @@ -84,21 +84,40 @@ async def build(self, **kwargs): return None -@pytest.mark.asyncio -async def test_stream_chat_turn_persists_assistant_reply() -> None: +class _InstantHealthChecker: + async def wait_until_healthy(self, **kwargs): + yield "Assistant container is healthy" + return + + +def _build_chat_turn_service( + *, + readiness: IngestionReadinessSnapshot, +) -> tuple[ExtractionChatTurnService, _InMemoryAgentSessionRepository]: repo = _InMemoryAgentSessionRepository() sticky = InMemoryStickySessionRuntimeManager() session_service = ExtractionAgentSessionService(repository=repo) - service = ExtractionChatTurnService( + runtime_service = StickySessionRuntimeService( session_service=session_service, skill_resolution_service=_StaticSkillResolutionService(), - ingestion_readiness_reader=_StaticIngestionReadinessReader( - IngestionReadinessSnapshot(1, 1), - ), + ingestion_readiness_reader=_StaticIngestionReadinessReader(readiness), sticky_runtime_manager=sticky, - chat_agent=DeterministicExtractionChatAgent(), bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_InstantHealthChecker(), + runtime_backend="memory", + sticky_health_timeout_seconds=5.0, + ) + service = ExtractionChatTurnService( + session_service=session_service, + runtime_service=runtime_service, + chat_agent=DeterministicExtractionChatAgent(), ) + return service, repo + + +@pytest.mark.asyncio +async def test_stream_chat_turn_persists_assistant_reply() -> None: + service, repo = _build_chat_turn_service(readiness=IngestionReadinessSnapshot(1, 1)) events = [ event @@ -123,19 +142,7 @@ async def test_stream_chat_turn_persists_assistant_reply() -> None: @pytest.mark.asyncio async def test_stream_chat_turn_wait_when_job_package_unprepared() -> None: - repo = _InMemoryAgentSessionRepository() - sticky = InMemoryStickySessionRuntimeManager() - session_service = ExtractionAgentSessionService(repository=repo) - service = ExtractionChatTurnService( - session_service=session_service, - skill_resolution_service=_StaticSkillResolutionService(), - ingestion_readiness_reader=_StaticIngestionReadinessReader( - IngestionReadinessSnapshot(2, 0), - ), - sticky_runtime_manager=sticky, - chat_agent=DeterministicExtractionChatAgent(), - bootstrap_builder=_StaticBootstrapBuilder(), - ) + service, repo = _build_chat_turn_service(readiness=IngestionReadinessSnapshot(2, 0)) events = [ event @@ -158,3 +165,25 @@ async def test_stream_chat_turn_wait_when_job_package_unprepared() -> None: ) assert active is not None assert active.runtime_context["job_package"]["phase"] == "awaiting_job_package" + + +@pytest.mark.asyncio +async def test_stream_runtime_warmup_marks_memory_backend_ready() -> None: + service, _repo = _build_chat_turn_service(readiness=IngestionReadinessSnapshot(1, 1)) + + events = [ + event + async for event in service.stream_runtime_warmup( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + ) + ] + + assert any(event.get("type") == "ready" for event in events) + done = events[-1] + assert done["type"] == "done" + assert done["ok"] is True + assert done.get("ready") is True diff --git a/src/api/tests/unit/extraction/infrastructure/test_vertex_runtime_env.py b/src/api/tests/unit/extraction/infrastructure/test_vertex_runtime_env.py new file mode 100644 index 000000000..a5fe91d39 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_vertex_runtime_env.py @@ -0,0 +1,42 @@ +"""Unit tests for Vertex runtime environment helpers.""" + +from __future__ import annotations + +import pytest + +from extraction.infrastructure.vertex_runtime_env import ( + build_vertex_container_env, + vertex_enabled_from_env, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("1", True), + ("true", True), + ("yes", True), + ("0", False), + ("", False), + (None, False), + ], +) +def test_vertex_enabled_from_env( + monkeypatch: pytest.MonkeyPatch, value: str | None, expected: bool +) -> None: + if value is None: + monkeypatch.delenv("CLAUDE_CODE_USE_VERTEX", raising=False) + else: + monkeypatch.setenv("CLAUDE_CODE_USE_VERTEX", value) + assert vertex_enabled_from_env() is expected + + +def test_build_vertex_container_env_includes_project_and_region() -> None: + env = build_vertex_container_env( + project_id="my-gcp-project", + region="us-central1", + ) + assert env["CLAUDE_CODE_USE_VERTEX"] == "1" + assert env["ANTHROPIC_VERTEX_PROJECT_ID"] == "my-gcp-project" + assert env["CLOUD_ML_REGION"] == "us-central1" + assert env["VERTEXAI_LOCATION"] == "us-central1" diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 47e6995d8..e75957954 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -96,7 +96,7 @@ import { type MutationLogEntryPreviewPage, type MutationLogRunRecord, } from '@/utils/kgMutationLogs' -import { streamExtractionChatTurn } from '@/utils/kgExtractionChat' +import { streamExtractionChatTurn, streamRuntimeWarmup } from '@/utils/kgExtractionChat' import { useGraphApi } from '@/composables/api/useGraphApi' const runtimeConfig = useRuntimeConfig() @@ -209,6 +209,10 @@ const sessionForbidden = ref(false) const sessionForbiddenReason = ref<string | null>(null) const clearingChat = ref(false) const sendingChat = ref(false) +const runtimeWarming = ref(false) +const runtimeReady = ref(false) +const runtimeWarmupError = ref<string | null>(null) +let runtimeWarmupGeneration = 0 const extractionSession = ref<ExtractionSessionResponse | null>(null) const sessionHistory = ref<ExtractionSessionHistoryItem[]>([]) const draftMessage = ref('') @@ -300,6 +304,8 @@ const graphManagementInputPlaceholder = computed( ) const sessionStatusLabel = computed(() => { + if (runtimeWarming.value) return 'Starting assistant' + if (!runtimeReady.value && runtimeWarmupError.value) return 'Runtime unavailable' if (sessionLoading.value) return 'Loading session' if (clearingChat.value) return 'Resetting chat' if (extractionSession.value?.id) { @@ -308,6 +314,19 @@ const sessionStatusLabel = computed(() => { return 'No active session' }) +const chatInputDisabled = computed( + () => workspaceForbidden.value || runtimeWarming.value || !runtimeReady.value, +) + +const chatInputDisabledReason = computed(() => { + if (workspaceForbidden.value) return workspaceForbiddenReason.value + if (runtimeWarming.value) return 'Starting Graph Management Assistant…' + if (!runtimeReady.value) { + return runtimeWarmupError.value ?? 'Assistant runtime is not ready yet.' + } + return null +}) + const graphManagementRailItems = computed(() => { if (!statusProjection.value) return [] return buildGraphManagementRailItems({ @@ -878,11 +897,64 @@ function syncActivityLinesFromSession() { sessionActivityLines.value = candidate.filter( (line): line is string => typeof line === 'string' && line.trim().length > 0, ) - } else { + } else if (!runtimeWarming.value) { sessionActivityLines.value = [] } } +async function warmupAssistantRuntime() { + if (!kgId.value || activeStep.value !== 'graph-management') return + if (sessionForbidden.value || workspaceForbidden.value) { + runtimeReady.value = false + return + } + + const generation = ++runtimeWarmupGeneration + runtimeWarming.value = true + runtimeReady.value = false + runtimeWarmupError.value = null + sessionActivityLines.value = ['Preparing Graph Management Assistant runtime…'] + + try { + for await (const event of streamRuntimeWarmup({ + apiBaseUrl: String(runtimeConfig.public.apiBaseUrl ?? ''), + accessToken: accessToken.value, + tenantId: currentTenantId.value, + kgId: kgId.value, + sessionMode: sharedSessionMode.value, + uiMode: graphManagementMode.value, + })) { + if (generation !== runtimeWarmupGeneration) return + if (event.type === 'thinking' && Array.isArray(event.recent)) { + sessionActivityLines.value = event.recent.filter(Boolean) + } + if (event.type === 'wait' && event.message) { + sessionActivityLines.value = [event.message] + } + if (event.type === 'ready') { + sessionActivityLines.value = ['Assistant container ready'] + } + if (event.type === 'done') { + if (event.ok !== true) { + throw new Error(event.error?.message ?? 'Runtime warmup failed') + } + runtimeReady.value = event.ready === true || event.wait === true + } + } + await loadExtractionSession() + } catch (err) { + runtimeWarmupError.value = extractErrorMessage(err) + runtimeReady.value = false + toast.error('Failed to start Graph Management Assistant', { + description: runtimeWarmupError.value, + }) + } finally { + if (generation === runtimeWarmupGeneration) { + runtimeWarming.value = false + } + } +} + async function sendChatMessage(message: string) { if (sessionForbidden.value || !shouldApplyMutationResult(sessionForbidden.value)) { toast.error('Chat unavailable', { @@ -1051,22 +1123,29 @@ watch(tenantVersion, () => { watch( () => statusProjection.value?.workspace_mode, - () => { + async () => { if (activeStep.value === 'graph-management') { syncGraphManagementState() - loadExtractionSession() + await loadExtractionSession() + await warmupAssistantRuntime() } }, ) watch( - () => [activeStep.value, route.query.gm_mode] as const, - () => { + () => [activeStep.value, route.query.gm_mode, sharedSessionMode.value] as const, + async () => { if (activeStep.value === 'graph-management') { syncGraphManagementState() - loadExtractionSession() + await loadExtractionSession() loadSessionHistory() loadGraphManagementDataSources() + await warmupAssistantRuntime() + } else { + runtimeWarmupGeneration += 1 + runtimeWarming.value = false + runtimeReady.value = false + runtimeWarmupError.value = null } }, ) @@ -1675,8 +1754,8 @@ watch(selectedOpsDataSourceId, () => { :activity-lines="sessionActivityLines" :forbidden="sessionForbidden" :forbidden-reason="sessionForbiddenReason" - :input-disabled="workspaceForbidden" - :input-disabled-reason="workspaceForbiddenReason" + :input-disabled="chatInputDisabled" + :input-disabled-reason="chatInputDisabledReason" @refresh="loadExtractionSession" @clear-chat="clearChat" @send-message="sendChatMessage" diff --git a/src/dev-ui/app/tests/kg-extraction-chat.test.ts b/src/dev-ui/app/tests/kg-extraction-chat.test.ts index efdfd0a37..ce4cbd96c 100644 --- a/src/dev-ui/app/tests/kg-extraction-chat.test.ts +++ b/src/dev-ui/app/tests/kg-extraction-chat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { streamExtractionChatTurn } from '../utils/kgExtractionChat' +import { streamExtractionChatTurn, streamRuntimeWarmup } from '../utils/kgExtractionChat' describe('kgExtractionChat', () => { it('targets the extraction chat NDJSON endpoint with UI mode in body', async () => { @@ -40,4 +40,50 @@ describe('kgExtractionChat', () => { globalThis.fetch = originalFetch } }) + + it('targets the proactive runtime warmup NDJSON endpoint with UI mode in body', async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(input), init }) + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + '{"type":"ready","runtime_base_url":"http://runtime:8787"}\n{"type":"done","ok":true,"ready":true}\n', + ), + ) + controller.close() + }, + }) + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/x-ndjson' } }) + }) as typeof fetch + + try { + const events = [] + for await (const event of streamRuntimeWarmup({ + apiBaseUrl: 'http://api.test', + accessToken: 'token', + tenantId: 'tenant-1', + kgId: 'kg-1', + sessionMode: 'schema_bootstrap', + uiMode: 'initial-schema-design', + })) { + events.push(event) + } + + expect(events).toEqual([ + { type: 'ready', runtime_base_url: 'http://runtime:8787' }, + { type: 'done', ok: true, ready: true }, + ]) + expect(calls[0]?.url).toContain( + '/extraction/knowledge-graphs/kg-1/sessions/schema_bootstrap/runtime/warm', + ) + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + graph_management_ui_mode: 'initial-schema-design', + }) + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index aa4ef8086..0a17e3b08 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -553,6 +553,8 @@ describe('KG-MANAGE-017 - chat input keyboard contract', () => { expect(sharedConversationPanelVue).toContain('Shift+Enter for a new line') expect(sharedConversationPanelVue).toContain("emit('sendMessage'") expect(manageWorkspaceVue).toContain('streamExtractionChatTurn') + expect(manageWorkspaceVue).toContain('streamRuntimeWarmup') + expect(manageWorkspaceVue).toContain('warmupAssistantRuntime') expect(manageWorkspaceVue).toContain('@send-message="sendChatMessage"') }) }) diff --git a/src/dev-ui/app/utils/kgExtractionChat.ts b/src/dev-ui/app/utils/kgExtractionChat.ts index 9ed1cd13a..847eb7b20 100644 --- a/src/dev-ui/app/utils/kgExtractionChat.ts +++ b/src/dev-ui/app/utils/kgExtractionChat.ts @@ -1,14 +1,16 @@ -/** Stream graph-management chat turns over NDJSON. */ +/** Stream graph-management chat turns and proactive runtime warmup over NDJSON. */ import type { GraphManagementMode } from '@/utils/kgGraphManagement' export interface ExtractionChatStreamEvent { - type: 'thinking' | 'wait' | 'done' + type: 'thinking' | 'wait' | 'ready' | 'done' recent?: string[] phase?: string message?: string + runtime_base_url?: string ok?: boolean reply?: string | null + ready?: boolean wait?: boolean error?: { code: string; message: string } } @@ -23,35 +25,29 @@ export interface StreamExtractionChatOptions { message: string } -export async function* streamExtractionChatTurn( - options: StreamExtractionChatOptions, -): AsyncGenerator<ExtractionChatStreamEvent> { - const headers: Record<string, string> = { - 'Content-Type': 'application/json', - Accept: 'application/x-ndjson', - } - if (options.accessToken) { - headers.Authorization = `Bearer ${options.accessToken}` - } - if (options.tenantId) { - headers['X-Tenant-ID'] = options.tenantId - } +export interface StreamRuntimeWarmupOptions { + apiBaseUrl: string + accessToken: string | null + tenantId: string | null + kgId: string + sessionMode: 'schema_bootstrap' | 'extraction_operations' + uiMode: GraphManagementMode +} - const response = await fetch( - `${options.apiBaseUrl}/extraction/knowledge-graphs/${encodeURIComponent(options.kgId)}/sessions/${options.sessionMode}/chat`, - { - method: 'POST', - headers, - body: JSON.stringify({ - message: options.message, - graph_management_ui_mode: options.uiMode, - }), - }, - ) +async function* streamNdjsonPost( + url: string, + headers: Record<string, string>, + body: Record<string, unknown>, +): AsyncGenerator<ExtractionChatStreamEvent> { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) if (!response.ok) { - const body = await response.text().catch(() => '') - throw new Error(body || `${response.status} ${response.statusText}`) + const text = await response.text().catch(() => '') + throw new Error(text || `${response.status} ${response.statusText}`) } const reader = response.body?.getReader() @@ -80,3 +76,41 @@ export async function* streamExtractionChatTurn( yield JSON.parse(tail) as ExtractionChatStreamEvent } } + +function buildExtractionHeaders( + accessToken: string | null, + tenantId: string | null, +): Record<string, string> { + const headers: Record<string, string> = { + 'Content-Type': 'application/json', + Accept: 'application/x-ndjson', + } + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + if (tenantId) { + headers['X-Tenant-ID'] = tenantId + } + return headers +} + +export async function* streamRuntimeWarmup( + options: StreamRuntimeWarmupOptions, +): AsyncGenerator<ExtractionChatStreamEvent> { + const headers = buildExtractionHeaders(options.accessToken, options.tenantId) + const url = `${options.apiBaseUrl}/extraction/knowledge-graphs/${encodeURIComponent(options.kgId)}/sessions/${options.sessionMode}/runtime/warm` + yield* streamNdjsonPost(url, headers, { + graph_management_ui_mode: options.uiMode, + }) +} + +export async function* streamExtractionChatTurn( + options: StreamExtractionChatOptions, +): AsyncGenerator<ExtractionChatStreamEvent> { + const headers = buildExtractionHeaders(options.accessToken, options.tenantId) + const url = `${options.apiBaseUrl}/extraction/knowledge-graphs/${encodeURIComponent(options.kgId)}/sessions/${options.sessionMode}/chat` + yield* streamNdjsonPost(url, headers, { + message: options.message, + graph_management_ui_mode: options.uiMode, + }) +} From 7c9c7676412f2a5c3818a21a6ff1a129b71e5360 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 29 May 2026 10:40:11 -0400 Subject: [PATCH 65/80] fix(iam): pin stable Keycloak user IDs for dev alice and bob Prevent JIT provisioning conflicts when Keycloak re-imports the realm and Postgres still holds rows keyed by the previous SSO subject. Co-authored-by: Cursor <cursoragent@cursor.com> --- keycloak/realm.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/keycloak/realm.json b/keycloak/realm.json index 85bd32af7..209cdc656 100644 --- a/keycloak/realm.json +++ b/keycloak/realm.json @@ -76,6 +76,7 @@ ], "users": [ { + "id": "91bd9b81-5c1d-4307-8dcd-3b80dcc68894", "username": "alice", "enabled": true, "email": "alice@example.com", @@ -90,6 +91,7 @@ ] }, { + "id": "7ac7083e-42c8-4643-8b2f-052ffc579ea2", "username": "bob", "enabled": true, "email": "bob@example.com", From bbd91e922b7eb62ace438edcb80d512b8b3e758b Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 29 May 2026 15:53:51 -0400 Subject: [PATCH 66/80] fix(extraction): enable Vertex chat in sticky containers with host ADC Mount gcloud credentials at /gcloud/config and run sticky containers as the host UID so Claude Agent SDK can reach Vertex AI, while keeping the API root for Docker-out-of-Docker in dev. Co-authored-by: Cursor <cursoragent@cursor.com> --- Makefile | 3 +- compose.dev.yaml | 24 +- src/agent-runtime/Dockerfile | 8 +- .../kartograph_agent_runtime/executor.py | 94 +++++++- .../kartograph_agent_runtime/server.py | 51 +++- .../kartograph_agent_runtime/settings.py | 3 + .../application/chat_turn_service.py | 19 ++ .../sticky_session_runtime_service.py | 60 +++-- src/api/extraction/dependencies.py | 36 ++- .../container_workload_runtime.py | 139 ++++++++++- .../remote_sticky_container_chat_agent.py | 15 +- .../repositories/agent_session_repository.py | 1 + .../infrastructure/workload_runtime.py | 42 ++++ .../workload_runtime_factory.py | 3 + .../workload_runtime_settings.py | 32 ++- src/api/extraction/ports/runtime.py | 24 ++ src/api/extraction/presentation/routes.py | 4 +- .../container_runtime/cli_runtime.py | 19 ++ .../shared_kernel/container_runtime/ports.py | 5 + .../application/test_chat_turn_service.py | 96 ++++++++ .../test_sticky_session_runtime_service.py | 217 ++++++++++++++++++ .../test_container_workload_runtime.py | 26 +++ ...test_sticky_session_container_bootstrap.py | 16 +- .../test_workload_runtime_settings.py | 5 + .../extraction/presentation/test_routes.py | 8 +- .../extraction/SharedConversationPanel.vue | 18 +- .../pages/knowledge-graphs/[kgId]/manage.vue | 74 +++++- .../app/tests/kg-extraction-chat.test.ts | 36 +++ .../knowledge-graph-manage-workspace.test.ts | 4 +- src/dev-ui/app/utils/kgExtractionChat.ts | 17 +- 30 files changed, 1019 insertions(+), 80 deletions(-) create mode 100644 src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py diff --git a/Makefile b/Makefile index bda68a19e..4ff4f7b86 100755 --- a/Makefile +++ b/Makefile @@ -23,8 +23,9 @@ certs: .PHONY: dev dev: certs @echo "🧰 [Development] Starting application containers..." + docker compose -f compose.yaml -f compose.dev.yaml --profile build-only build agent-runtime docker compose -f compose.yaml build - docker compose -f compose.yaml -f compose.dev.yaml --profile ui up -d + HOST_UID=$$(id -u) HOST_GID=$$(id -g) docker compose -f compose.yaml -f compose.dev.yaml --profile ui up -d @echo "Done." @echo "----------------------------" @echo "API Root: http://localhost:8000" diff --git a/compose.dev.yaml b/compose.dev.yaml index 480388cdc..e48dc6de8 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -8,28 +8,36 @@ services: profiles: ["build-only"] api: - # Run as root in dev to handle host file permissions (any umask) - user: "${UID}:${GID}" + # Root required for Docker-out-of-Docker via mounted /var/run/docker.sock in dev + user: "0:0" environment: UV_CACHE_DIR: /tmp/uv-cache + HOST_UID: ${HOST_UID} + HOST_GID: ${HOST_GID} KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND: container KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE: auto KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_NETWORK: kartograph_kartograph KARTOGRAPH_EXTRACTION_RUNTIME_STICKY_IMAGE: kartograph-agent-runtime:dev KARTOGRAPH_EXTRACTION_RUNTIME_API_BASE_URL: http://api:8000 KARTOGRAPH_EXTRACTION_RUNTIME_JOB_PACKAGE_WORK_DIR: /tmp/kartograph/job_packages - KARTOGRAPH_EXTRACTION_RUNTIME_SKILLS_DIR: /app/skills - # Vertex AI for Claude Agent SDK (mirror k-extract; set in host .env or shell) - CLAUDE_CODE_USE_VERTEX: ${CLAUDE_CODE_USE_VERTEX:-} - ANTHROPIC_VERTEX_PROJECT_ID: ${ANTHROPIC_VERTEX_PROJECT_ID:-} - CLOUD_ML_REGION: ${CLOUD_ML_REGION:-us-east5} - KARTOGRAPH_GCLOUD_CONFIG_MOUNT: ${KARTOGRAPH_GCLOUD_CONFIG_MOUNT:-} + KARTOGRAPH_EXTRACTION_RUNTIME_SKILLS_DIR: ${PWD}/skills + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_RUN_UID: ${HOST_UID} + KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_RUN_GID: ${HOST_GID} + # Vertex AI for Claude Agent SDK in sticky assistant containers + CLAUDE_CODE_USE_VERTEX: "1" + ANTHROPIC_VERTEX_PROJECT_ID: itpc-gcp-hcm-pe-eng-claude + CLOUD_ML_REGION: us-east5 + KARTOGRAPH_GCLOUD_CONFIG_MOUNT: ${HOME}/.config/gcloud volumes: # Mount the entire app directory (minus venv) for hot-reload - ./src/api:/app:z - /app/.venv + # Shared with sibling sticky containers launched via the host Docker socket + - /tmp/kartograph/job_packages:/tmp/kartograph/job_packages # Allow API process to launch sibling extraction runtime containers locally - /var/run/docker.sock:/var/run/docker.sock + # Docker/Podman CLI from host (required for container runtime backend) + - ${DOCKER_BIN:-/usr/bin/docker}:/usr/bin/docker:ro command: - /bin/bash - -c diff --git a/src/agent-runtime/Dockerfile b/src/agent-runtime/Dockerfile index 54172815e..035c50698 100644 --- a/src/agent-runtime/Dockerfile +++ b/src/agent-runtime/Dockerfile @@ -4,10 +4,10 @@ WORKDIR /runtime COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /uvx /bin/ -COPY pyproject.toml /runtime/pyproject.toml +COPY pyproject.toml uv.lock /runtime/ COPY kartograph_agent_runtime /runtime/kartograph_agent_runtime -RUN uv sync --no-dev +RUN uv sync --frozen --no-dev ENV PATH="/runtime/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1 @@ -15,6 +15,6 @@ ENV PATH="/runtime/.venv/bin:$PATH" \ EXPOSE 8787 HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health').read()" || exit 1 + CMD /runtime/.venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health').read()" || exit 1 -CMD ["python", "-m", "kartograph_agent_runtime"] +CMD ["/runtime/.venv/bin/python", "-m", "kartograph_agent_runtime"] diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py index e55b1d58b..b4b54eb40 100644 --- a/src/agent-runtime/kartograph_agent_runtime/executor.py +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import os from collections.abc import AsyncIterator from typing import Any @@ -10,6 +11,8 @@ from kartograph_agent_runtime.tools import RuntimeTooling from kartograph_agent_runtime.vertex import build_claude_agent_env +_DEFAULT_TURN_TIMEOUT_SECONDS = 180.0 + def _build_system_prompt(agent_configuration: dict[str, Any]) -> str: system_prompt = str(agent_configuration.get("system_prompt") or "").strip() @@ -31,6 +34,41 @@ def _apply_model_env(settings: AgentRuntimeSettings) -> str: return "unconfigured" +def _extract_sdk_reply(message: Any) -> str | None: + result = getattr(message, "result", None) + if isinstance(result, str) and result.strip(): + return result.strip() + + content = getattr(message, "content", None) + if isinstance(content, str) and content.strip(): + return content.strip() + if isinstance(content, list): + parts: list[str] = [] + for block in content: + text = getattr(block, "text", None) + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + if parts: + return parts[-1] + return None + + +def _build_sdk_env(settings: AgentRuntimeSettings) -> dict[str, str]: + env = build_claude_agent_env(settings) + if settings.gcloud_config_dir.strip(): + env.setdefault("CLOUDSDK_CONFIG", settings.gcloud_config_dir.strip()) + if settings.google_application_credentials.strip(): + env.setdefault( + "GOOGLE_APPLICATION_CREDENTIALS", + settings.google_application_credentials.strip(), + ) + env.setdefault("HOME", settings.home_dir.strip() or "/tmp") + env.setdefault("API_TIMEOUT_MS", "120000") + env.setdefault("CLAUDE_CODE_MAX_RETRIES", "2") + env.setdefault("CLAUDE_ASYNC_AGENT_STALL_TIMEOUT_MS", "120000") + return env + + async def stream_turn_events( *, settings: AgentRuntimeSettings, @@ -38,6 +76,7 @@ async def stream_turn_events( ui_mode: str, agent_configuration: dict[str, Any], message_history: list[dict[str, Any]], + turn_timeout_seconds: float = _DEFAULT_TURN_TIMEOUT_SECONDS, ) -> AsyncIterator[dict[str, Any]]: auth_mode = _apply_model_env(settings) yield { @@ -58,6 +97,7 @@ async def stream_turn_events( agent_configuration=agent_configuration, message_history=message_history, auth_mode=auth_mode, + turn_timeout_seconds=turn_timeout_seconds, ): yield event return @@ -91,6 +131,7 @@ async def _stream_with_claude_sdk( agent_configuration: dict[str, Any], message_history: list[dict[str, Any]], auth_mode: str, + turn_timeout_seconds: float, ) -> AsyncIterator[dict[str, Any]]: from claude_agent_sdk import ClaudeAgentOptions, query @@ -113,18 +154,49 @@ async def _stream_with_claude_sdk( ], } - chunks: list[str] = [] + sdk_env = _build_sdk_env(settings) options = ClaudeAgentOptions( system_prompt=system_prompt, - env=build_claude_agent_env(settings), - ) - async for sdk_message in query(prompt=prompt, options=options): - text = getattr(sdk_message, "result", None) or getattr(sdk_message, "content", None) - if isinstance(text, str) and text.strip(): - chunks.append(text.strip()) - - reply = chunks[-1] if chunks else ( - "Claude Agent SDK completed without a textual response. " - "Retry with a more specific graph-management request." + env=sdk_env, + permission_mode="bypassPermissions", + max_turns=8, + setting_sources=[], ) + + reply: str | None = None + try: + async with asyncio.timeout(turn_timeout_seconds): + async for sdk_message in query(prompt=prompt, options=options): + extracted = _extract_sdk_reply(sdk_message) + if extracted: + reply = extracted + except TimeoutError: + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_TURN_TIMEOUT", + "message": ( + f"Claude Agent SDK did not complete within {int(turn_timeout_seconds)}s. " + "Check Vertex credentials and model access for this project." + ), + }, + } + return + except Exception as exc: # noqa: BLE001 + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_TURN_FAILED", + "message": str(exc), + }, + } + return + + if not reply: + reply = ( + "Claude Agent SDK completed without a textual response. " + "Retry with a more specific graph-management request." + ) yield {"type": "done", "ok": True, "reply": reply} diff --git a/src/agent-runtime/kartograph_agent_runtime/server.py b/src/agent-runtime/kartograph_agent_runtime/server.py index 7df4322f5..87b89a70e 100644 --- a/src/agent-runtime/kartograph_agent_runtime/server.py +++ b/src/agent-runtime/kartograph_agent_runtime/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from collections.abc import AsyncIterator from typing import Any @@ -13,6 +14,8 @@ from kartograph_agent_runtime.executor import stream_turn_events from kartograph_agent_runtime.settings import AgentRuntimeSettings +logger = logging.getLogger(__name__) + app = FastAPI(title="Kartograph Agent Runtime", version="0.1.0") settings = AgentRuntimeSettings() @@ -31,14 +34,46 @@ async def health() -> dict[str, str]: @app.post("/v1/turn") async def stream_turn(request: TurnRequest) -> StreamingResponse: + logger.info( + "agent_runtime_turn_started session_id=%s ui_mode=%s message_len=%s", + settings.session_id, + request.ui_mode, + len(request.message), + ) + async def event_stream() -> AsyncIterator[str]: - async for event in stream_turn_events( - settings=settings, - message=request.message, - ui_mode=request.ui_mode, - agent_configuration=request.agent_configuration, - message_history=request.message_history, - ): - yield json.dumps(event) + "\n" + try: + async for event in stream_turn_events( + settings=settings, + message=request.message, + ui_mode=request.ui_mode, + agent_configuration=request.agent_configuration, + message_history=request.message_history, + ): + if event.get("type") == "done": + logger.info( + "agent_runtime_turn_finished session_id=%s ok=%s", + settings.session_id, + event.get("ok"), + ) + yield json.dumps(event) + "\n" + except Exception: + logger.exception( + "agent_runtime_turn_failed session_id=%s", + settings.session_id, + ) + yield ( + json.dumps( + { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_RUNTIME_INTERNAL_ERROR", + "message": "Agent runtime failed while processing the turn.", + }, + } + ) + + "\n" + ) return StreamingResponse(event_stream(), media_type="application/x-ndjson") diff --git a/src/agent-runtime/kartograph_agent_runtime/settings.py b/src/agent-runtime/kartograph_agent_runtime/settings.py index 724e190f9..fd8e6048f 100644 --- a/src/agent-runtime/kartograph_agent_runtime/settings.py +++ b/src/agent-runtime/kartograph_agent_runtime/settings.py @@ -25,6 +25,9 @@ class AgentRuntimeSettings(BaseSettings): anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY") vertex_project_id: str = Field(default="", alias="ANTHROPIC_VERTEX_PROJECT_ID") vertex_region: str = Field(default="us-east5", alias="CLOUD_ML_REGION") + gcloud_config_dir: str = Field(default="", alias="CLOUDSDK_CONFIG") + google_application_credentials: str = Field(default="", alias="GOOGLE_APPLICATION_CREDENTIALS") + home_dir: str = Field(default="/tmp", alias="HOME") def vertex_enabled(self) -> bool: return vertex_enabled_from_env() diff --git a/src/api/extraction/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py index 812771730..8046f47a9 100644 --- a/src/api/extraction/application/chat_turn_service.py +++ b/src/api/extraction/application/chat_turn_service.py @@ -127,8 +127,10 @@ async def stream_chat_turn( session.message_history.append({"role": "user", "content": trimmed}) session.updated_at = datetime.now(UTC) + await self._session_service.save_session(session) assistant_reply: str | None = None + stream_failed = False async for event in self._chat_agent.stream_turn( session=session, user_message=trimmed, @@ -143,6 +145,8 @@ async def stream_chat_turn( if event.get("type") == "done": if event.get("ok") is True and event.get("reply"): assistant_reply = str(event["reply"]) + elif event.get("ok") is not True: + stream_failed = True yield event if assistant_reply: @@ -150,3 +154,18 @@ async def stream_chat_turn( session.updated_at = datetime.now(UTC) session.runtime_context.pop("activity_lines", None) await self._session_service.save_session(session) + elif stream_failed: + session.updated_at = datetime.now(UTC) + await self._session_service.save_session(session) + else: + yield { + "type": "done", + "ok": False, + "error": { + "code": "AGENT_STREAM_INCOMPLETE", + "message": ( + "Graph Management Assistant ended the turn without a final response. " + "Check sticky container logs for Vertex or SDK errors." + ), + }, + } diff --git a/src/api/extraction/application/sticky_session_runtime_service.py b/src/api/extraction/application/sticky_session_runtime_service.py index e76604f43..f1ddb6de8 100644 --- a/src/api/extraction/application/sticky_session_runtime_service.py +++ b/src/api/extraction/application/sticky_session_runtime_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator from datetime import UTC, datetime from typing import Any @@ -19,6 +20,7 @@ from extraction.ports.runtime import IStickySessionRuntimeManager, StickySessionRuntimeLease from extraction.ports.sticky_runtime_health import IStickyRuntimeHealthChecker from extraction.ports.sticky_session_bootstrap import IStickySessionBootstrapBuilder +from shared_kernel.container_runtime.ports import ContainerRuntimeError class StickySessionRuntimeService: @@ -82,12 +84,22 @@ async def ensure_runtime_for_chat( session: ExtractionAgentSession, ) -> AsyncIterator[dict[str, Any]]: sticky = session.runtime_context.get("sticky_runtime", {}) - if ( - isinstance(sticky.get("runtime_base_url"), str) - and sticky.get("phase") == "ready" - and sticky.get("container_id") - ): + container_id = sticky.get("container_id") + persisted_container_id = container_id if isinstance(container_id, str) else None + + lease = await asyncio.to_thread( + self._sticky_runtime_manager.try_resolve_active_lease, + session_id=session.id, + container_id=persisted_container_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + ) + if lease is not None: + session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="ready") + await self._session_service.save_session(session) return + async for event in self._stream_prepare_runtime( tenant_id=tenant_id, user_id=user_id, @@ -165,7 +177,8 @@ async def _stream_prepare_runtime( return if self._runtime_backend != "container": - lease = self._sticky_runtime_manager.get_or_start_runtime( + lease = await asyncio.to_thread( + self._sticky_runtime_manager.get_or_start_runtime, session_id=session.id, user_id=user_id, knowledge_graph_id=knowledge_graph_id, @@ -203,13 +216,34 @@ async def _stream_prepare_runtime( "Starting isolated Claude Agent SDK container", ], } - lease = self._sticky_runtime_manager.get_or_start_runtime( - session_id=session.id, - user_id=user_id, - knowledge_graph_id=knowledge_graph_id, - mode=mode.value, - bootstrap=bootstrap, - ) + lease: StickySessionRuntimeLease + try: + lease = await asyncio.to_thread( + self._sticky_runtime_manager.get_or_start_runtime, + session_id=session.id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode.value, + bootstrap=bootstrap, + ) + except ContainerRuntimeError as exc: + session.runtime_context["sticky_runtime"] = { + "phase": "failed", + "status": "failed", + } + if persist_session: + await self._session_service.save_session(session) + yield { + "type": "done", + "ok": False, + "ready": False, + "error": { + "code": "RUNTIME_START_FAILED", + "message": str(exc), + }, + } + return + session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="starting") yield { "type": "thinking", diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index d81916c98..343c8fcd8 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -51,13 +51,11 @@ def get_ephemeral_extraction_worker_launcher() -> IEphemeralExtractionWorkerLaun return create_ephemeral_extraction_worker_launcher() -def get_extraction_agent_session_service( - session: Annotated[AsyncSession, Depends(get_write_session)], - sticky_runtime_manager: Annotated[ - IStickySessionRuntimeManager, Depends(get_sticky_session_runtime_manager) - ], +def _build_extraction_agent_session_service( + session: AsyncSession, + *, + sticky_runtime_manager: IStickySessionRuntimeManager | None = None, ) -> ExtractionAgentSessionService: - """Get ExtractionAgentSessionService instance.""" skill_resolution_service = ExtractionSkillResolutionService( override_repository=ExtractionSkillOverrideRepository() ) @@ -69,6 +67,26 @@ def get_extraction_agent_session_service( ) +def get_extraction_agent_session_service( + session: Annotated[AsyncSession, Depends(get_write_session)], +) -> ExtractionAgentSessionService: + """Get ExtractionAgentSessionService for read/create session routes.""" + return _build_extraction_agent_session_service(session) + + +def get_extraction_agent_session_service_with_runtime( + session: Annotated[AsyncSession, Depends(get_write_session)], + sticky_runtime_manager: Annotated[ + IStickySessionRuntimeManager, Depends(get_sticky_session_runtime_manager) + ], +) -> ExtractionAgentSessionService: + """Get ExtractionAgentSessionService for routes that reset sticky containers.""" + return _build_extraction_agent_session_service( + session, + sticky_runtime_manager=sticky_runtime_manager, + ) + + def get_extraction_chat_turn_service( session: Annotated[AsyncSession, Depends(get_write_session)], sticky_runtime_manager: Annotated[ @@ -80,10 +98,8 @@ def get_extraction_chat_turn_service( skill_resolution_service = ExtractionSkillResolutionService( override_repository=ExtractionSkillOverrideRepository() ) - session_service = ExtractionAgentSessionService( - repository=ExtractionAgentSessionRepository(session=session), - skill_resolution_service=skill_resolution_service, - run_metrics_reader=ExtractionSessionRunMetricsReader(session=session), + session_service = _build_extraction_agent_session_service( + session, sticky_runtime_manager=sticky_runtime_manager, ) bootstrap_builder = StickySessionBootstrapBuilder( diff --git a/src/api/extraction/infrastructure/container_workload_runtime.py b/src/api/extraction/infrastructure/container_workload_runtime.py index eda814fb9..1a10f80af 100644 --- a/src/api/extraction/infrastructure/container_workload_runtime.py +++ b/src/api/extraction/infrastructure/container_workload_runtime.py @@ -29,6 +29,18 @@ def _sanitize_container_name(prefix: str, identifier: str) -> str: return name[:63].rstrip("-_.") or f"{prefix}runtime" +_GCLOUD_ADC_FILENAME = "application_default_credentials.json" + + +def _gcloud_adc_env(*, container_config_path: str) -> dict[str, str]: + base = container_config_path.rstrip("/") + return { + "CLOUDSDK_CONFIG": base, + "GOOGLE_APPLICATION_CREDENTIALS": f"{base}/{_GCLOUD_ADC_FILENAME}", + "HOME": "/tmp", + } + + class ContainerStickySessionRuntimeManager(IStickySessionRuntimeManager): """Sticky runtime manager backed by real container lifecycle operations.""" @@ -47,6 +59,9 @@ def __init__( vertex_region: str = "us-east5", vertex_enabled: bool = False, gcloud_config_mount: str | None = None, + gcloud_config_container_path: str = "/gcloud/config", + container_run_uid: int | None = None, + container_run_gid: int | None = None, ) -> None: self._container_runtime = container_runtime self._sticky_image = sticky_image @@ -60,6 +75,9 @@ def __init__( self._vertex_region = vertex_region self._vertex_enabled = vertex_enabled self._gcloud_config_mount = gcloud_config_mount + self._gcloud_config_container_path = gcloud_config_container_path + self._container_run_uid = container_run_uid + self._container_run_gid = container_run_gid self._leases: dict[str, StickySessionRuntimeLease] = {} def get_or_start_runtime( @@ -87,6 +105,18 @@ def get_or_start_runtime( self._leases[session_id] = refreshed return refreshed + adopted = self._adopt_running_container_if_present( + session_id=session_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + now=now, + container_id_hint=None, + ) + if adopted is not None: + self._leases[session_id] = adopted + return adopted + if existing is not None: self._terminate_container(existing.container_id) @@ -134,6 +164,105 @@ def cleanup_expired(self, *, now: datetime) -> list[str]: terminated.append(lease.container_id) return terminated + def try_resolve_active_lease( + self, + *, + session_id: str, + user_id: str = "", + knowledge_graph_id: str = "", + mode: str = "", + container_id: str | None = None, + ) -> StickySessionRuntimeLease | None: + now = datetime.now(UTC) + lease = self._leases.get(session_id) + if ( + lease is not None + and lease.expires_at > now + and self._container_runtime.is_running(lease.container_id) + ): + refreshed = replace( + lease, + last_activity_at=now, + expires_at=now + self._session_ttl, + status="active", + ) + self._leases[session_id] = refreshed + return refreshed + + adopt_user_id = lease.user_id if lease is not None else user_id + adopt_kg_id = lease.knowledge_graph_id if lease is not None else knowledge_graph_id + adopt_mode = lease.mode if lease is not None else mode + hints = [container_id] if container_id else [] + container_name = _sanitize_container_name("kartograph-sticky-", session_id) + named_id = self._container_runtime.container_id_for_name(container_name) + if named_id is not None: + hints.append(named_id) + + for hint in hints: + if not hint or not self._container_runtime.is_running(hint): + continue + adopted = self._adopt_running_container_if_present( + session_id=session_id, + user_id=adopt_user_id, + knowledge_graph_id=adopt_kg_id, + mode=adopt_mode, + now=now, + container_id_hint=hint, + ) + if adopted is not None: + self._leases[session_id] = adopted + return adopted + return None + + def is_runtime_active( + self, + *, + session_id: str, + container_id: str | None = None, + user_id: str = "", + knowledge_graph_id: str = "", + mode: str = "", + ) -> bool: + return ( + self.try_resolve_active_lease( + session_id=session_id, + container_id=container_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + is not None + ) + + def _adopt_running_container_if_present( + self, + *, + session_id: str, + user_id: str, + knowledge_graph_id: str, + mode: str, + now: datetime, + container_id_hint: str | None, + ) -> StickySessionRuntimeLease | None: + container_name = _sanitize_container_name("kartograph-sticky-", session_id) + container_id = container_id_hint or self._container_runtime.container_id_for_name( + container_name + ) + if container_id is None: + return None + runtime_base_url = f"http://{container_name}:{self._sticky_service_port}" + return StickySessionRuntimeLease( + session_id=session_id, + container_id=container_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + status="active", + last_activity_at=now, + expires_at=now + self._session_ttl, + runtime_base_url=runtime_base_url, + ) + def _start_runtime( self, *, @@ -186,8 +315,13 @@ def _start_runtime( ) ) if self._gcloud_config_mount: - binds.append(f"{self._gcloud_config_mount}:/root/.config/gcloud:ro") - env.setdefault("CLOUDSDK_CONFIG", "/root/.config/gcloud") + container_gcloud = self._gcloud_config_container_path.rstrip("/") + binds.append(f"{self._gcloud_config_mount}:{container_gcloud}:ro") + env.update(_gcloud_adc_env(container_config_path=container_gcloud)) + + container_user: str | None = None + if self._container_run_uid is not None and self._container_run_gid is not None: + container_user = f"{self._container_run_uid}:{self._container_run_gid}" launched = self._container_runtime.run( ContainerRunSpec( @@ -196,6 +330,7 @@ def _start_runtime( env=env, binds=tuple(binds), network=self._container_network, + user=container_user, labels={ "kartograph.runtime.kind": "sticky", "kartograph.session_id": session_id, diff --git a/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py b/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py index f20b5079c..34957bf45 100644 --- a/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py +++ b/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py @@ -10,13 +10,21 @@ from extraction.domain.entities.agent_session import ExtractionAgentSession from extraction.domain.value_objects import GraphManagementUiMode +from extraction.infrastructure.workload_runtime_settings import ( + get_extraction_workload_runtime_settings, +) class RemoteStickyContainerChatAgent: """Delegates conversational turns to the sticky session Claude agent runtime.""" - def __init__(self, *, request_timeout_seconds: float = 120.0) -> None: - self._request_timeout_seconds = request_timeout_seconds + def __init__(self, *, request_timeout_seconds: float | None = None) -> None: + settings = get_extraction_workload_runtime_settings() + self._request_timeout_seconds = ( + request_timeout_seconds + if request_timeout_seconds is not None + else settings.sticky_turn_timeout_seconds + 30.0 + ) async def stream_turn( self, @@ -47,7 +55,8 @@ async def stream_turn( url = f"{runtime_base_url.rstrip('/')}/v1/turn" try: - async with httpx.AsyncClient(timeout=self._request_timeout_seconds) as client: + timeout = httpx.Timeout(10.0, read=self._request_timeout_seconds) + async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream("POST", url, json=payload) as response: if response.status_code >= 400: body = await response.aread() diff --git a/src/api/extraction/infrastructure/repositories/agent_session_repository.py b/src/api/extraction/infrastructure/repositories/agent_session_repository.py index 156301e48..01596dc64 100644 --- a/src/api/extraction/infrastructure/repositories/agent_session_repository.py +++ b/src/api/extraction/infrastructure/repositories/agent_session_repository.py @@ -42,6 +42,7 @@ async def save(self, session: ExtractionAgentSession) -> None: model.updated_at = session.updated_at model.archived_at = session.archived_at await self._session.flush() + await self._session.commit() async def get_by_id(self, session_id: str) -> ExtractionAgentSession | None: stmt = select(ExtractionAgentSessionModel).where( diff --git a/src/api/extraction/infrastructure/workload_runtime.py b/src/api/extraction/infrastructure/workload_runtime.py index 6af2f5655..7544854f7 100644 --- a/src/api/extraction/infrastructure/workload_runtime.py +++ b/src/api/extraction/infrastructure/workload_runtime.py @@ -89,6 +89,48 @@ def cleanup_expired(self, *, now: datetime) -> list[str]: terminated.append(lease.container_id) return terminated + def try_resolve_active_lease( + self, + *, + session_id: str, + user_id: str = "", + knowledge_graph_id: str = "", + mode: str = "", + container_id: str | None = None, + ) -> StickySessionRuntimeLease | None: + now = datetime.now(UTC) + lease = self._leases.get(session_id) + if lease is not None and lease.expires_at > now: + refreshed = replace( + lease, + last_activity_at=now, + expires_at=now + self._session_ttl, + status="active", + ) + self._leases[session_id] = refreshed + return refreshed + return None + + def is_runtime_active( + self, + *, + session_id: str, + container_id: str | None = None, + user_id: str = "", + knowledge_graph_id: str = "", + mode: str = "", + ) -> bool: + return ( + self.try_resolve_active_lease( + session_id=session_id, + container_id=container_id, + user_id=user_id, + knowledge_graph_id=knowledge_graph_id, + mode=mode, + ) + is not None + ) + class ScopedWorkloadCredentialIssuer: """Issues short-lived tenant/KG scoped credentials for extraction workloads.""" diff --git a/src/api/extraction/infrastructure/workload_runtime_factory.py b/src/api/extraction/infrastructure/workload_runtime_factory.py index f04a17760..8642c89f5 100644 --- a/src/api/extraction/infrastructure/workload_runtime_factory.py +++ b/src/api/extraction/infrastructure/workload_runtime_factory.py @@ -73,6 +73,9 @@ def create_sticky_session_runtime_manager( vertex_region=resolved.vertex_region, vertex_enabled=resolved.vertex_enabled(), gcloud_config_mount=resolved.gcloud_config_mount, + gcloud_config_container_path=resolved.gcloud_config_container_path, + container_run_uid=resolved.container_run_uid, + container_run_gid=resolved.container_run_gid, ) diff --git a/src/api/extraction/infrastructure/workload_runtime_settings.py b/src/api/extraction/infrastructure/workload_runtime_settings.py index da2541971..7c1ae8f34 100644 --- a/src/api/extraction/infrastructure/workload_runtime_settings.py +++ b/src/api/extraction/infrastructure/workload_runtime_settings.py @@ -27,7 +27,13 @@ class ExtractionWorkloadRuntimeSettings(BaseSettings): container_network: str | None = Field(default=None) sticky_image: str = Field(default="kartograph-agent-runtime:dev") worker_image: str = Field(default="docker.io/library/busybox:1.36") - sticky_command: tuple[str, ...] = Field(default=("python", "-m", "kartograph_agent_runtime")) + sticky_command: tuple[str, ...] = Field( + default=(), + description=( + "Optional container entrypoint override. Empty uses the image CMD " + "(kartograph-agent-runtime invokes the venv interpreter)." + ), + ) worker_command: tuple[str, ...] = Field(default=("sleep", "3600")) sticky_service_port: int = Field(default=8787, ge=1024, le=65535) container_skills_mount: str = Field(default="/app/skills") @@ -37,9 +43,13 @@ class ExtractionWorkloadRuntimeSettings(BaseSettings): skills_dir: str = Field(default="/app/skills") api_base_url: str = Field(default="http://api:8000") sticky_health_timeout_seconds: float = Field(default=90.0, ge=5.0, le=600.0) + sticky_turn_timeout_seconds: float = Field(default=180.0, ge=30.0, le=900.0) vertex_project_id: str = Field(default="") vertex_region: str = Field(default="us-east5") gcloud_config_mount: str | None = Field(default=None) + gcloud_config_container_path: str = Field(default="/gcloud/config") + container_run_uid: int | None = Field(default=None) + container_run_gid: int | None = Field(default=None) def vertex_enabled(self) -> bool: return vertex_enabled_from_env() @@ -63,6 +73,26 @@ def _apply_vertex_env_aliases(self) -> "ExtractionWorkloadRuntimeSettings": gcloud = os.getenv("KARTOGRAPH_GCLOUD_CONFIG_MOUNT", "").strip() if gcloud: object.__setattr__(self, "gcloud_config_mount", gcloud) + if self.container_run_uid is None: + for key in ( + "KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_RUN_UID", + "HOST_UID", + "UID", + ): + raw = os.getenv(key, "").strip() + if raw.isdigit(): + object.__setattr__(self, "container_run_uid", int(raw)) + break + if self.container_run_gid is None: + for key in ( + "KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_RUN_GID", + "HOST_GID", + "GID", + ): + raw = os.getenv(key, "").strip() + if raw.isdigit(): + object.__setattr__(self, "container_run_gid", int(raw)) + break return self @field_validator("sticky_command", "worker_command", mode="before") diff --git a/src/api/extraction/ports/runtime.py b/src/api/extraction/ports/runtime.py index b24ccae88..5a46b12e8 100644 --- a/src/api/extraction/ports/runtime.py +++ b/src/api/extraction/ports/runtime.py @@ -103,6 +103,30 @@ def cleanup_expired(self, *, now: datetime) -> list[str]: """Terminate and remove expired sticky runtimes; return container IDs.""" ... + def try_resolve_active_lease( + self, + *, + session_id: str, + user_id: str = "", + knowledge_graph_id: str = "", + mode: str = "", + container_id: str | None = None, + ) -> StickySessionRuntimeLease | None: + """Return an active lease for the session, adopting a running container if needed.""" + ... + + def is_runtime_active( + self, + *, + session_id: str, + container_id: str | None = None, + user_id: str = "", + knowledge_graph_id: str = "", + mode: str = "", + ) -> bool: + """Return True when the sticky runtime for the session is running.""" + ... + class IEphemeralExtractionWorkerLauncher(Protocol): """Launches short-lived extraction workers with scoped credentials.""" diff --git a/src/api/extraction/presentation/routes.py b/src/api/extraction/presentation/routes.py index fbd922010..4e6dba76f 100644 --- a/src/api/extraction/presentation/routes.py +++ b/src/api/extraction/presentation/routes.py @@ -12,6 +12,7 @@ from extraction.application.chat_turn_service import ExtractionChatTurnService from extraction.dependencies import ( get_extraction_agent_session_service, + get_extraction_agent_session_service_with_runtime, get_extraction_chat_turn_service, ) from extraction.domain.value_objects import ExtractionSessionMode @@ -146,7 +147,8 @@ async def clear_chat( mode: ExtractionSessionMode, current_user: Annotated[CurrentUser, Depends(get_current_user)], service: Annotated[ - ExtractionAgentSessionService, Depends(get_extraction_agent_session_service) + ExtractionAgentSessionService, + Depends(get_extraction_agent_session_service_with_runtime), ], authz: Annotated[AuthorizationProvider, Depends(get_spicedb_client)], ) -> ExtractionSessionResponse: diff --git a/src/api/shared_kernel/container_runtime/cli_runtime.py b/src/api/shared_kernel/container_runtime/cli_runtime.py index 29ee2e817..865ae7d15 100644 --- a/src/api/shared_kernel/container_runtime/cli_runtime.py +++ b/src/api/shared_kernel/container_runtime/cli_runtime.py @@ -36,6 +36,8 @@ def run(self, spec: ContainerRunSpec) -> ContainerRunResult: command.extend(["--volume", bind]) if spec.network is not None: command.extend(["--network", spec.network]) + if spec.user is not None: + command.extend(["--user", spec.user]) command.append(spec.image) if spec.command: command.extend(spec.command) @@ -76,6 +78,23 @@ def is_running(self, container_id: str) -> bool: ) return result.stdout.strip().lower() == "true" + def container_id_for_name(self, name: str) -> str | None: + """Return the running container ID for a fixed container name, if any.""" + result = subprocess.run( + [self._binary, "inspect", "-f", "{{.Id}}", name], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + container_id = result.stdout.strip() + if not container_id: + return None + if not self.is_running(container_id): + return None + return container_id + def _execute(self, command: list[str]) -> str: result = subprocess.run( command, diff --git a/src/api/shared_kernel/container_runtime/ports.py b/src/api/shared_kernel/container_runtime/ports.py index 3824eb4bb..97a464806 100644 --- a/src/api/shared_kernel/container_runtime/ports.py +++ b/src/api/shared_kernel/container_runtime/ports.py @@ -23,6 +23,7 @@ class ContainerRunSpec: network: str | None = None detach: bool = True remove_on_exit: bool = False + user: str | None = None @dataclass(frozen=True) @@ -51,3 +52,7 @@ def remove(self, container_id: str, *, force: bool = False) -> None: def is_running(self, container_id: str) -> bool: """Return True when the container exists and is running.""" ... + + def container_id_for_name(self, name: str) -> str | None: + """Return the running container ID for a fixed container name, if any.""" + ... diff --git a/src/api/tests/unit/extraction/application/test_chat_turn_service.py b/src/api/tests/unit/extraction/application/test_chat_turn_service.py index e990fe1c7..77f71c573 100644 --- a/src/api/tests/unit/extraction/application/test_chat_turn_service.py +++ b/src/api/tests/unit/extraction/application/test_chat_turn_service.py @@ -187,3 +187,99 @@ async def test_stream_runtime_warmup_marks_memory_backend_ready() -> None: assert done["type"] == "done" assert done["ok"] is True assert done.get("ready") is True + + +class _FailingChatAgent: + async def stream_turn(self, **kwargs): + yield { + "type": "done", + "ok": False, + "error": {"code": "MODEL_ERROR", "message": "Vertex request failed"}, + } + + +class _IncompleteChatAgent: + async def stream_turn(self, **kwargs): + yield {"type": "thinking", "recent": ["Working…"]} + + +@pytest.mark.asyncio +async def test_stream_chat_turn_emits_error_when_agent_stream_incomplete() -> None: + repo = _InMemoryAgentSessionRepository() + sticky = InMemoryStickySessionRuntimeManager() + session_service = ExtractionAgentSessionService(repository=repo) + runtime_service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader( + IngestionReadinessSnapshot(1, 1) + ), + sticky_runtime_manager=sticky, + bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_InstantHealthChecker(), + runtime_backend="memory", + sticky_health_timeout_seconds=5.0, + ) + service = ExtractionChatTurnService( + session_service=session_service, + runtime_service=runtime_service, + chat_agent=_IncompleteChatAgent(), + ) + + events = [ + event + async for event in service.stream_chat_turn( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + message="Hello!", + ) + ] + + done = events[-1] + assert done["type"] == "done" + assert done["ok"] is False + assert done["error"]["code"] == "AGENT_STREAM_INCOMPLETE" + + +@pytest.mark.asyncio +async def test_stream_chat_turn_persists_user_message_when_agent_fails() -> None: + repo = _InMemoryAgentSessionRepository() + sticky = InMemoryStickySessionRuntimeManager() + session_service = ExtractionAgentSessionService(repository=repo) + runtime_service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader( + IngestionReadinessSnapshot(1, 1) + ), + sticky_runtime_manager=sticky, + bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_InstantHealthChecker(), + runtime_backend="memory", + sticky_health_timeout_seconds=5.0, + ) + service = ExtractionChatTurnService( + session_service=session_service, + runtime_service=runtime_service, + chat_agent=_FailingChatAgent(), + ) + + events = [ + event + async for event in service.stream_chat_turn( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + message="Hello!", + ) + ] + + assert events[-1]["ok"] is False + active = await repo.find_active_by_scope("user-1", "kg-1", ExtractionSessionMode.SCHEMA_BOOTSTRAP) + assert active is not None + assert active.message_history[-1] == {"role": "user", "content": "Hello!"} diff --git a/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py new file mode 100644 index 000000000..337a5919c --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py @@ -0,0 +1,217 @@ +"""Unit tests for StickySessionRuntimeService.""" + +from __future__ import annotations + +import pytest + +from extraction.application.agent_session_service import ExtractionAgentSessionService +from extraction.application.sticky_session_runtime_service import StickySessionRuntimeService +from extraction.domain.value_objects import ( + ExtractionSessionMode, + GraphManagementUiMode, + IngestionReadinessSnapshot, +) +from extraction.infrastructure.workload_runtime import InMemoryStickySessionRuntimeManager +from shared_kernel.container_runtime.ports import ContainerRuntimeError + + +class _InMemoryAgentSessionRepository: + def __init__(self) -> None: + self._sessions = {} + + async def save(self, session) -> None: + from dataclasses import replace + + self._sessions[session.id] = replace(session) + + async def get_by_id(self, session_id: str): + session = self._sessions.get(session_id) + if session is None: + return None + from dataclasses import replace + + return replace(session) + + async def find_active_by_scope(self, user_id: str, knowledge_graph_id: str, mode): + for session in self._sessions.values(): + if ( + session.user_id == user_id + and session.knowledge_graph_id == knowledge_graph_id + and session.mode == mode + and session.archived_at is None + ): + from dataclasses import replace + + return replace(session) + return None + + async def list_by_scope(self, user_id: str, knowledge_graph_id: str, mode=None): + return [] + + +class _StaticSkillResolutionService: + async def resolve_for_graph_management_turn(self, **kwargs): + return type( + "_Resolved", + (), + { + "system_prompt": "system", + "prompt_hierarchy": ("platform",), + "guardrails": ("scope",), + "skills": {}, + }, + )() + + +class _StaticIngestionReadinessReader: + async def read_for_knowledge_graph(self, *, knowledge_graph_id: str): + return IngestionReadinessSnapshot(0, 0) + + +class _StaticBootstrapBuilder: + async def build(self, **kwargs): + return None + + +class _InstantHealthChecker: + async def wait_until_healthy(self, **kwargs): + return + yield # pragma: no cover + + +class _FailingStickyRuntimeManager(InMemoryStickySessionRuntimeManager): + def get_or_start_runtime(self, **kwargs): + raise ContainerRuntimeError("docker run failed: image not found") + + +@pytest.mark.asyncio +async def test_stream_runtime_warmup_surfaces_container_start_failure() -> None: + repo = _InMemoryAgentSessionRepository() + session_service = ExtractionAgentSessionService(repository=repo) + service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader(), + sticky_runtime_manager=_FailingStickyRuntimeManager(), + bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_InstantHealthChecker(), + runtime_backend="container", + sticky_health_timeout_seconds=5.0, + ) + + events = [ + event + async for event in service.stream_runtime_warmup( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + ) + ] + + done = events[-1] + assert done["type"] == "done" + assert done["ok"] is False + assert done["error"]["code"] == "RUNTIME_START_FAILED" + assert "image not found" in done["error"]["message"] + + +class _OnceInactiveStickyRuntimeManager(InMemoryStickySessionRuntimeManager): + def __init__(self) -> None: + super().__init__() + self._checked = False + + def try_resolve_active_lease(self, **kwargs): + if not self._checked: + self._checked = True + return None + return super().try_resolve_active_lease(**kwargs) + + +@pytest.mark.asyncio +async def test_ensure_runtime_for_chat_reprepares_when_persisted_runtime_is_inactive() -> None: + repo = _InMemoryAgentSessionRepository() + session_service = ExtractionAgentSessionService(repository=repo) + sticky = _OnceInactiveStickyRuntimeManager() + service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader(), + sticky_runtime_manager=sticky, + bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_InstantHealthChecker(), + runtime_backend="memory", + sticky_health_timeout_seconds=5.0, + ) + session = await session_service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + session.runtime_context["sticky_runtime"] = { + "container_id": "dead-container", + "status": "active", + "runtime_base_url": "memory://sticky-runtime", + "phase": "ready", + } + await session_service.save_session(session) + + events = [ + event + async for event in service.ensure_runtime_for_chat( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + session=session, + ) + ] + + assert any(event.get("type") == "ready" for event in events) + assert session.runtime_context["sticky_runtime"]["container_id"] != "dead-container" + assert sticky.try_resolve_active_lease(session_id=session.id) is not None + + +@pytest.mark.asyncio +async def test_ensure_runtime_for_chat_reuses_running_container_without_reprepare() -> None: + repo = _InMemoryAgentSessionRepository() + session_service = ExtractionAgentSessionService(repository=repo) + sticky = InMemoryStickySessionRuntimeManager() + service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader(), + sticky_runtime_manager=sticky, + bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_InstantHealthChecker(), + runtime_backend="memory", + sticky_health_timeout_seconds=5.0, + ) + session = await session_service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + sticky.get_or_start_runtime( + session_id=session.id, + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP.value, + ) + + events = [ + event + async for event in service.ensure_runtime_for_chat( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + session=session, + ) + ] + + assert events == [] + assert session.runtime_context["sticky_runtime"]["phase"] == "ready" diff --git a/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py b/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py index 1947e8bd1..16c761822 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py +++ b/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py @@ -20,6 +20,7 @@ class TestContainerStickySessionRuntimeManager: def test_reuses_running_container_for_active_session(self) -> None: runtime = MagicMock() runtime.is_running.return_value = True + runtime.container_id_for_name.return_value = None runtime.run.return_value = ContainerRunResult( container_id="container-1", name="kartograph-sticky-session-1", @@ -47,9 +48,33 @@ def test_reuses_running_container_for_active_session(self) -> None: assert first.container_id == second.container_id == "container-1" runtime.run.assert_called_once() + def test_adopts_running_container_after_process_restart(self) -> None: + runtime = MagicMock() + runtime.is_running.return_value = True + runtime.container_id_for_name.return_value = "container-existing" + manager = ContainerStickySessionRuntimeManager( + container_runtime=runtime, + sticky_image="busybox:1.36", + sticky_command=(), + session_ttl=timedelta(minutes=30), + container_network="kartograph_kartograph", + ) + + lease = manager.try_resolve_active_lease( + session_id="session-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode="schema_bootstrap", + ) + + assert lease is not None + assert lease.container_id == "container-existing" + runtime.run.assert_not_called() + def test_reset_stops_existing_container_and_starts_new_one(self) -> None: runtime = MagicMock() runtime.is_running.return_value = True + runtime.container_id_for_name.return_value = None runtime.run.side_effect = [ ContainerRunResult(container_id="container-1", name="name-1"), ContainerRunResult(container_id="container-2", name="name-2"), @@ -81,6 +106,7 @@ def test_reset_stops_existing_container_and_starts_new_one(self) -> None: def test_cleanup_expired_terminates_and_returns_container_ids(self) -> None: runtime = MagicMock() runtime.is_running.return_value = True + runtime.container_id_for_name.return_value = None runtime.run.return_value = ContainerRunResult( container_id="container-1", name="name-1", diff --git a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py index 8a297b999..75a0c45fb 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py +++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py @@ -15,13 +15,19 @@ def test_start_runtime_mounts_skills_workspace_and_injects_token() -> None: runtime = MagicMock() + runtime.is_running.return_value = False + runtime.container_id_for_name.return_value = None runtime.run.return_value = ContainerRunResult(container_id="container-1", name="name-1") manager = ContainerStickySessionRuntimeManager( container_runtime=runtime, sticky_image="kartograph-agent-runtime:dev", - sticky_command=("python", "-m", "kartograph_agent_runtime"), + sticky_command=(), session_ttl=timedelta(minutes=30), container_network="kartograph_kartograph", + gcloud_config_mount="/host/.config/gcloud", + gcloud_config_container_path="/gcloud/config", + container_run_uid=1000, + container_run_gid=1000, ) issuer = ScopedWorkloadCredentialIssuer(default_ttl=timedelta(minutes=10)) credentials = issuer.issue_for_sticky_session(tenant_id="tenant-1", knowledge_graph_id="kg-1") @@ -42,8 +48,16 @@ def test_start_runtime_mounts_skills_workspace_and_injects_token() -> None: ) spec: ContainerRunSpec = runtime.run.call_args.args[0] + assert spec.command == () assert spec.network == "kartograph_kartograph" assert spec.env["KARTOGRAPH_WORKLOAD_TOKEN"] == credentials.token assert "/tmp/skills:/app/skills:ro" in spec.binds assert "/tmp/session-work:/workspace:ro" in spec.binds + assert "/host/.config/gcloud:/gcloud/config:ro" in spec.binds + assert spec.env["CLOUDSDK_CONFIG"] == "/gcloud/config" + assert spec.env["GOOGLE_APPLICATION_CREDENTIALS"] == ( + "/gcloud/config/application_default_credentials.json" + ) + assert spec.env["HOME"] == "/tmp" + assert spec.user == "1000:1000" assert lease.runtime_base_url.startswith("http://kartograph-sticky-") diff --git a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py index a5902bca3..f03834f1e 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py +++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py @@ -8,6 +8,11 @@ class TestExtractionWorkloadRuntimeSettings: + def test_default_sticky_command_uses_image_entrypoint(self) -> None: + settings = ExtractionWorkloadRuntimeSettings() + + assert settings.sticky_command == () + def test_parses_command_strings_into_tuple(self) -> None: settings = ExtractionWorkloadRuntimeSettings( sticky_command="sleep 3600", diff --git a/src/api/tests/unit/extraction/presentation/test_routes.py b/src/api/tests/unit/extraction/presentation/test_routes.py index f82a6f5c1..5b6d479a6 100644 --- a/src/api/tests/unit/extraction/presentation/test_routes.py +++ b/src/api/tests/unit/extraction/presentation/test_routes.py @@ -116,7 +116,10 @@ async def read_relationships( @pytest.fixture def extraction_client(): - from extraction.dependencies import get_extraction_agent_session_service + from extraction.dependencies import ( + get_extraction_agent_session_service, + get_extraction_agent_session_service_with_runtime, + ) from extraction.presentation import router from iam.dependencies.user import get_current_user from infrastructure.authorization_dependencies import get_spicedb_client @@ -125,6 +128,9 @@ def extraction_client(): repo = _InMemoryAgentSessionRepository() service = ExtractionAgentSessionService(repository=repo) app.dependency_overrides[get_extraction_agent_session_service] = lambda: service + app.dependency_overrides[get_extraction_agent_session_service_with_runtime] = ( + lambda: service + ) app.dependency_overrides[get_current_user] = lambda: CurrentUser( user_id=UserId(value="user-123"), username="alice", diff --git a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue index 2fb7482ab..dbd43ee16 100644 --- a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue +++ b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue @@ -31,6 +31,7 @@ const props = withDefaults(defineProps<{ loading?: boolean clearing?: boolean sending?: boolean + preparingRuntime?: boolean draftMessage?: string activityLines?: string[] inputPlaceholder?: string @@ -46,6 +47,7 @@ const props = withDefaults(defineProps<{ loading: false, clearing: false, sending: false, + preparingRuntime: false, draftMessage: '', activityLines: () => [], inputPlaceholder: 'Describe what you want to do in this graph management session…', @@ -79,6 +81,16 @@ const chatInputDisabled = computed( () => props.loading || props.clearing || props.sending || props.inputDisabled || props.forbidden, ) +const showRuntimeActivity = computed( + () => props.preparingRuntime || props.sending, +) + +const runtimeActivityTitle = computed(() => + props.preparingRuntime && !props.sending + ? 'Starting assistant container…' + : 'Thinking...', +) + const thinkingDisplaySlots = computed(() => { const src = props.activityLines.filter(Boolean) if (src.length === 0) return [''] @@ -296,7 +308,7 @@ onMounted(() => { </div> <div - v-if="sending" + v-if="showRuntimeActivity" class="flex gap-3 text-muted-foreground" aria-live="polite" aria-busy="true" @@ -309,7 +321,7 @@ onMounted(() => { > <div class="mb-2 flex items-center gap-2 text-foreground"> <Loader2 class="size-4 shrink-0 animate-spin text-primary" aria-hidden="true" /> - <span class="font-medium tracking-tight">Thinking...</span> + <span class="font-medium tracking-tight">{{ runtimeActivityTitle }}</span> </div> <ol class="m-0 list-none space-y-2 border-l-2 border-primary/25 pl-3"> <li @@ -335,7 +347,7 @@ onMounted(() => { </div> <p - v-if="messageHistory.length === 0 && !sending" + v-if="messageHistory.length === 0 && !showRuntimeActivity" class="py-8 text-center text-sm text-muted-foreground" > No messages yet. Send a prompt or use validate/transition actions to drive session activity. diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index e75957954..87701447d 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -303,9 +303,28 @@ const graphManagementInputPlaceholder = computed( () => GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS[graphManagementMode.value], ) +const conversationSessionForPanel = computed<ExtractionSessionResponse | null>(() => { + if (!extractionSession.value) return null + if (!runtimeReady.value) { + return { + ...extractionSession.value, + message_history: [], + } + } + return extractionSession.value +}) + const sessionStatusLabel = computed(() => { - if (runtimeWarming.value) return 'Starting assistant' - if (!runtimeReady.value && runtimeWarmupError.value) return 'Runtime unavailable' + const latestActivity = sessionActivityLines.value.filter(Boolean).at(-1) + if (runtimeWarming.value) { + return latestActivity ?? 'Starting assistant' + } + if (!runtimeReady.value && latestActivity) { + return latestActivity + } + if (!runtimeReady.value && runtimeWarmupError.value) { + return runtimeWarmupError.value + } if (sessionLoading.value) return 'Loading session' if (clearingChat.value) return 'Resetting chat' if (extractionSession.value?.id) { @@ -314,6 +333,16 @@ const sessionStatusLabel = computed(() => { return 'No active session' }) +const showRuntimeWarmupProgress = computed( + () => + runtimeWarming.value + || (!runtimeReady.value && sessionActivityLines.value.some((line) => line.trim().length > 0)), +) + +const conversationPanelLoading = computed( + () => sessionLoading.value && !showRuntimeWarmupProgress.value, +) + const chatInputDisabled = computed( () => workspaceForbidden.value || runtimeWarming.value || !runtimeReady.value, ) @@ -787,6 +816,11 @@ async function loadMutationLogEntryPreviews(offset = 0) { } } +async function refreshGraphManagementSession() { + await loadExtractionSession() + await warmupAssistantRuntime() +} + async function loadExtractionSession() { if (!kgId.value || activeStep.value !== 'graph-management') return sessionLoading.value = true @@ -796,6 +830,15 @@ async function loadExtractionSession() { `/extraction/knowledge-graphs/${kgId.value}/sessions/${sharedSessionMode.value}/active`, ) syncActivityLinesFromSession() + const stickyPhase = extractionSession.value?.runtime_context?.sticky_runtime + if ( + stickyPhase + && typeof stickyPhase === 'object' + && (stickyPhase as { phase?: string }).phase === 'ready' + && !runtimeWarming.value + ) { + runtimeReady.value = true + } sessionForbidden.value = false sessionForbiddenReason.value = null } catch (err) { @@ -891,6 +934,7 @@ function onMutationRunKeydown(event: KeyboardEvent, runId: string) { } function syncActivityLinesFromSession() { + if (runtimeWarming.value || showRuntimeWarmupProgress.value) return const context = extractionSession.value?.runtime_context ?? {} const candidate = context.activity_lines ?? context.ndjson_activity_lines ?? context.thinking_lines if (Array.isArray(candidate)) { @@ -936,7 +980,10 @@ async function warmupAssistantRuntime() { } if (event.type === 'done') { if (event.ok !== true) { - throw new Error(event.error?.message ?? 'Runtime warmup failed') + throw new Error( + event.error?.message + ?? 'Runtime warmup failed before the assistant container was ready.', + ) } runtimeReady.value = event.ready === true || event.wait === true } @@ -945,6 +992,11 @@ async function warmupAssistantRuntime() { } catch (err) { runtimeWarmupError.value = extractErrorMessage(err) runtimeReady.value = false + const lines = sessionActivityLines.value.filter(Boolean) + sessionActivityLines.value = [ + ...lines, + `Runtime startup failed: ${runtimeWarmupError.value}`, + ] toast.error('Failed to start Graph Management Assistant', { description: runtimeWarmupError.value, }) @@ -1127,7 +1179,6 @@ watch( if (activeStep.value === 'graph-management') { syncGraphManagementState() await loadExtractionSession() - await warmupAssistantRuntime() } }, ) @@ -1137,9 +1188,11 @@ watch( async () => { if (activeStep.value === 'graph-management') { syncGraphManagementState() - await loadExtractionSession() - loadSessionHistory() - loadGraphManagementDataSources() + await Promise.all([ + loadExtractionSession(), + loadSessionHistory(), + loadGraphManagementDataSources(), + ]) await warmupAssistantRuntime() } else { runtimeWarmupGeneration += 1 @@ -1747,16 +1800,17 @@ watch(selectedOpsDataSourceId, () => { :description="graphManagementChatDescription" :input-placeholder="graphManagementInputPlaceholder" :session-status-label="sessionStatusLabel" - :session="extractionSession" - :loading="sessionLoading" + :session="conversationSessionForPanel" + :loading="conversationPanelLoading" :clearing="clearingChat" :sending="sendingChat" + :preparing-runtime="runtimeWarming" :activity-lines="sessionActivityLines" :forbidden="sessionForbidden" :forbidden-reason="sessionForbiddenReason" :input-disabled="chatInputDisabled" :input-disabled-reason="chatInputDisabledReason" - @refresh="loadExtractionSession" + @refresh="refreshGraphManagementSession" @clear-chat="clearChat" @send-message="sendChatMessage" /> diff --git a/src/dev-ui/app/tests/kg-extraction-chat.test.ts b/src/dev-ui/app/tests/kg-extraction-chat.test.ts index ce4cbd96c..e1155a39f 100644 --- a/src/dev-ui/app/tests/kg-extraction-chat.test.ts +++ b/src/dev-ui/app/tests/kg-extraction-chat.test.ts @@ -86,4 +86,40 @@ describe('kgExtractionChat', () => { globalThis.fetch = originalFetch } }) + + it('throws when the NDJSON stream ends without a terminal done event', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('{"type":"thinking","recent":["Still working…"]}\n'), + ) + controller.close() + }, + }) + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/x-ndjson' } }) + }) as typeof fetch + + try { + const iterator = streamExtractionChatTurn({ + apiBaseUrl: 'http://api.test', + accessToken: 'token', + tenantId: 'tenant-1', + kgId: 'kg-1', + sessionMode: 'schema_bootstrap', + uiMode: 'initial-schema-design', + message: 'Hello', + }) + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _event of iterator) { + // drain stream + } + }).rejects.toThrow('stream ended before completion') + } finally { + globalThis.fetch = originalFetch + } + }) }) diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 0a17e3b08..c4c935fc8 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -197,7 +197,7 @@ describe('KG-MANAGE-002 - workspace hub tile set', () => { expect(manageWorkspaceVue).toContain('Relationship Types') expect(manageWorkspaceVue).toContain('Mutation Runs') expect(manageWorkspaceHubTs).toContain('Data sources') - expect(manageWorkspaceHubTs).toContain('Design') + expect(manageWorkspaceHubTs).toContain('Graph Management') expect(manageWorkspaceHubTs).toContain('Mutation logs') expect(manageWorkspaceHubTs).toContain('Maintain') }) @@ -555,6 +555,8 @@ describe('KG-MANAGE-017 - chat input keyboard contract', () => { expect(manageWorkspaceVue).toContain('streamExtractionChatTurn') expect(manageWorkspaceVue).toContain('streamRuntimeWarmup') expect(manageWorkspaceVue).toContain('warmupAssistantRuntime') + expect(manageWorkspaceVue).toContain('preparing-runtime') + expect(manageWorkspaceVue).toContain('conversationSessionForPanel') expect(manageWorkspaceVue).toContain('@send-message="sendChatMessage"') }) }) diff --git a/src/dev-ui/app/utils/kgExtractionChat.ts b/src/dev-ui/app/utils/kgExtractionChat.ts index 847eb7b20..93912c172 100644 --- a/src/dev-ui/app/utils/kgExtractionChat.ts +++ b/src/dev-ui/app/utils/kgExtractionChat.ts @@ -57,6 +57,7 @@ async function* streamNdjsonPost( const decoder = new TextDecoder() let buffer = '' + let sawTerminalDone = false while (true) { const { done, value } = await reader.read() @@ -67,13 +68,25 @@ async function* streamNdjsonPost( for (const line of parts) { const trimmed = line.trim() if (!trimmed) continue - yield JSON.parse(trimmed) as ExtractionChatStreamEvent + const event = JSON.parse(trimmed) as ExtractionChatStreamEvent + if (event.type === 'done') { + sawTerminalDone = true + } + yield event } } const tail = buffer.trim() if (tail) { - yield JSON.parse(tail) as ExtractionChatStreamEvent + const event = JSON.parse(tail) as ExtractionChatStreamEvent + if (event.type === 'done') { + sawTerminalDone = true + } + yield event + } + + if (!sawTerminalDone) { + throw new Error('Graph Management Assistant stream ended before completion.') } } From 2f0b6f5a6b3d741aee181c826105e5a68da7fc86 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 29 May 2026 15:55:48 -0400 Subject: [PATCH 67/80] refactor(ui): rename workspace hub Design phase to Graph Management Drop the redundant branch tip column from the KG data sources table. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../knowledge-graphs/[kgId]/data-sources/index.vue | 9 +-------- src/dev-ui/app/tests/kg-data-sources-phase1.test.ts | 2 +- src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts | 9 +++++++-- src/dev-ui/app/utils/kgManageWorkspaceHub.ts | 12 ++++++------ 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue index f28fe0d80..728feaa65 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -669,7 +669,7 @@ watch(tenantVersion, async () => { </div> <div v-else class="overflow-x-auto rounded-md border"> - <table class="w-full min-w-[1120px] text-sm"> + <table class="w-full min-w-[1000px] text-sm"> <thead> <tr class="border-b bg-muted/50 text-left"> <th class="px-3 py-2 font-medium">Source</th> @@ -679,7 +679,6 @@ watch(tenantVersion, async () => { <th class="px-3 py-2 font-medium">Last extraction baseline</th> <th class="px-3 py-2 font-medium">Ingested at</th> <th class="px-3 py-2 font-medium">Newest unpulled</th> - <th class="px-3 py-2 font-medium">Branch tip</th> <th class="px-3 py-2 font-medium">Actions</th> </tr> </thead> @@ -779,12 +778,6 @@ watch(tenantVersion, async () => { }} </div> </td> - <td class="px-3 py-2 font-mono text-xs text-muted-foreground"> - <span :title="resolveBranchTipCommit(ds) || ''"> - {{ shortCommitHash(resolveBranchTipCommit(ds)) }} - </span> - <div class="mt-0.5 text-[10px] text-muted-foreground">remote tip</div> - </td> <td class="px-3 py-2"> <div class="flex flex-wrap gap-1"> <Button size="sm" variant="ghost" class="h-7 px-2 text-[10px]" @click="openEditConfig(ds)"> diff --git a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts index 35768bbf0..a290ed065 100644 --- a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts +++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts @@ -62,7 +62,7 @@ describe('KG data sources phase1 layout', () => { expect(phase1Vue).toContain('Newest unpulled') expect(phase1Vue).toContain('Last extraction baseline') expect(phase1Vue).toContain('Ingested at') - expect(phase1Vue).toContain('Branch tip') + expect(phase1Vue).not.toContain('Branch tip') expect(phase1Vue).toContain('resolveNewestUnpulledCommit') }) }) diff --git a/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts b/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts index 858bafad6..7bf307675 100644 --- a/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts +++ b/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts @@ -49,6 +49,11 @@ describe('kgManageWorkspaceHub', () => { expect(tiles.find((tile) => tile.key === 'maintain')?.enabled).toBe(false) }) + it('labels the graph-management hub tile as Graph Management', () => { + const tiles = buildWorkspaceHubTiles(baseInput) + expect(tiles.find((tile) => tile.key === 'graph-management')?.title).toBe('Graph Management') + }) + it('marks sources phase complete when all sources are prepared', () => { const tiles = buildWorkspaceHubTiles({ ...baseInput, @@ -62,7 +67,7 @@ describe('kgManageWorkspaceHub', () => { ...baseInput, dataSourceCount: 2, preparedSourceCount: 2, - }).label).toBe('Design') + }).label).toBe('Graph Management') }) it('builds a primary next-step CTA while sources phase is incomplete', () => { @@ -84,6 +89,6 @@ describe('kgManageWorkspaceHub', () => { ...baseInput, dataSourceCount: 1, preparedSourceCount: 1, - })).toContain('Design') + })).toContain('Graph Management') }) }) diff --git a/src/dev-ui/app/utils/kgManageWorkspaceHub.ts b/src/dev-ui/app/utils/kgManageWorkspaceHub.ts index 3566df4ed..5a696a132 100644 --- a/src/dev-ui/app/utils/kgManageWorkspaceHub.ts +++ b/src/dev-ui/app/utils/kgManageWorkspaceHub.ts @@ -60,7 +60,7 @@ export function resolveWorkspaceHubPhaseBadge(input: WorkspaceHubOverview): Work return { label: 'Operations', variant: 'success' } } if (sourcesPhaseComplete(input)) { - return { label: 'Design', variant: 'warning' } + return { label: 'Graph Management', variant: 'warning' } } return { label: 'Data sources', variant: 'secondary' } } @@ -129,11 +129,11 @@ export function buildWorkspaceHubTiles(input: WorkspaceHubOverview): WorkspaceHu { step: 2, key: 'graph-management', - title: 'Design', + title: 'Graph Management', subtitle: designDone ? 'Schema validated · extraction operations available' : sourcesDone - ? 'Design assistant, schema bootstrap, and validation' + ? 'Graph management assistant, schema bootstrap, and validation' : 'Open anytime; prepare data sources to clear later gates', to: resolveStepDestination(input.kgId, 'graph-management'), enabled: true, @@ -167,7 +167,7 @@ export function buildWorkspaceHubTiles(input: WorkspaceHubOverview): WorkspaceHu : 'Incremental graph updates from new commits', to: resolveStepDestination(input.kgId, 'maintain'), enabled: designDone, - lockedReason: designDone ? null : 'Complete design validation before maintenance.', + lockedReason: designDone ? null : 'Complete graph management validation before maintenance.', highlight: highlightKey === 'maintain', tone: toneFor(4, maintainCard.status === 'ready' && input.maintenanceReadyCount === 0, designDone, maintainCard.status), linkLabel: linkLabelFor(maintainCard.actionLabel, maintainCard.status === 'ready' && input.maintenanceReadyCount === 0), @@ -254,10 +254,10 @@ export function workspaceHubStepBadgeClass(item: { export function workspaceHubDescription(input: WorkspaceHubOverview): string { if (!sourcesPhaseComplete(input)) { - return 'Finish ingestion under Data sources, then continue through Design. Green tiles mark completed gates; the highlighted tile is your current focus.' + return 'Finish ingestion under Data sources, then continue through Graph Management. Green tiles mark completed gates; the highlighted tile is your current focus.' } if (!designPhaseComplete(input)) { - return 'Use Design for the assistant and schema bootstrap. Green tiles use Revisit; the highlighted tile is your suggested next step.' + return 'Use Graph Management for the assistant and schema bootstrap. Green tiles use Revisit; the highlighted tile is your suggested next step.' } return 'Continue with mutation logs or maintenance, or Revisit any completed step below.' } From 9f7a04e7c804a5547eca2d0a3795de5a487bd24a Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 29 May 2026 15:56:59 -0400 Subject: [PATCH 68/80] repair env/api.env --- env/api.env | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/env/api.env b/env/api.env index 868ea6bf0..6cdd3da20 100644 --- a/env/api.env +++ b/env/api.env @@ -10,10 +10,7 @@ SPICEDB_ENDPOINT="spicedb:50051" SPICEDB_PRESHARED_KEY="changeme" KARTOGRAPH_CORS_ORIGINS=["http://localhost:3000"] KARTOGRAPH_IAM_BOOTSTRAP_ADMIN_USERNAMES='["alice"]' -KARTOGRAPH_IAM_SINGLE_TENANT_MODE=true -# Generate with uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +KARTOGRAPH_IAM_SINGLE_TENANT_MODE=false KARTOGRAPH_MGMT_ENCRYPTION_KEY="vwN4rUcH-KL-UyJsL8hc6apftRUTovwec6L2M5uF5OE=" -# Extraction runtime defaults to in-memory adapters. Set backend=container and -# mount /var/run/docker.sock (see compose.dev.yaml) for local container execution. KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND=memory KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE=auto \ No newline at end of file From e6a9305d14dc415b550b485272795b7edea3d50a Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Fri, 29 May 2026 16:24:35 -0400 Subject: [PATCH 69/80] fix(extraction): materialize JobPackages into sticky assistant workspace Load prepared archives even in schema-design mode, refresh the workspace on chat reuse, point Claude SDK at /workspace, and remove sibling sticky and worker containers during make down. Co-authored-by: Cursor <cursoragent@cursor.com> --- Makefile | 3 + .../kartograph_agent_runtime/executor.py | 60 ++++++++++++++++++- src/agent-runtime/tests/test_executor.py | 33 +++++++++- .../sticky_session_materialization.py | 25 ++++++++ .../sticky_session_runtime_service.py | 20 ++++++- .../sticky_session_workdir_materializer.py | 6 +- .../test_sticky_session_materialization.py | 40 +++++++++++++ .../test_sticky_session_runtime_service.py | 29 ++++++++- ...est_sticky_session_workdir_materializer.py | 14 +++++ 9 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 src/api/extraction/application/sticky_session_materialization.py create mode 100644 src/api/tests/unit/extraction/application/test_sticky_session_materialization.py diff --git a/Makefile b/Makefile index 4ff4f7b86..62679cad5 100755 --- a/Makefile +++ b/Makefile @@ -36,6 +36,9 @@ dev: certs .PHONY: down down: docker compose -f compose.yaml -f compose.dev.yaml down + @echo "Stopping Graph Management sticky and worker containers..." + -@docker ps -aq --filter name=kartograph-sticky- | xargs -r docker rm -f + -@docker ps -aq --filter name=kartograph-worker- | xargs -r docker rm -f .PHONY: run diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py index b4b54eb40..5afea4bd7 100644 --- a/src/agent-runtime/kartograph_agent_runtime/executor.py +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -14,16 +14,64 @@ _DEFAULT_TURN_TIMEOUT_SECONDS = 180.0 -def _build_system_prompt(agent_configuration: dict[str, Any]) -> str: +def _build_system_prompt( + agent_configuration: dict[str, Any], + *, + workspace_appendix: str = "", +) -> str: system_prompt = str(agent_configuration.get("system_prompt") or "").strip() guardrails = agent_configuration.get("guardrails") or [] skills = agent_configuration.get("skills") or {} skill_lines = "\n".join(f"- {key}: {value}" for key, value in sorted(skills.items())) guardrail_lines = "\n".join(f"- {item}" for item in guardrails if str(item).strip()) - sections = [section for section in (system_prompt, guardrail_lines, skill_lines) if section] + sections = [ + section + for section in (system_prompt, guardrail_lines, skill_lines, workspace_appendix.strip()) + if section + ] return "\n\n".join(sections) or "You are the Graph Management Assistant." +def _build_workspace_prompt_appendix(settings: AgentRuntimeSettings) -> str: + from pathlib import Path + + root = Path(settings.workspace_dir) + repo_root = root / "repository-files" + if not repo_root.is_dir(): + return ( + f"## Session workspace\n" + f"Workspace mount: `{settings.workspace_dir}`\n" + "No prepared JobPackage repository files are materialized yet." + ) + + package_dirs = sorted(path for path in repo_root.iterdir() if path.is_dir()) + if not package_dirs: + return ( + f"## Session workspace\n" + f"Workspace mount: `{settings.workspace_dir}`\n" + "Prepared data sources exist, but repository files have not been extracted yet. " + "If the user asks about repository content, explain that ingestion context may " + "need to be re-prepared under Data sources." + ) + + lines = [ + "## Session workspace", + f"Workspace mount: `{settings.workspace_dir}`", + ( + "Prepared repository files live under " + "`repository-files/<job_package_id>/` relative to the workspace mount. " + "Use Read, Grep, and Glob tools against those paths." + ), + ] + for package_dir in package_dirs[:8]: + files = sorted(path for path in package_dir.rglob("*") if path.is_file()) + lines.append(f"- `{package_dir.name}`: {len(files)} file(s)") + for file_path in files[:4]: + rel = file_path.relative_to(package_dir).as_posix() + lines.append(f" - `{rel}`") + return "\n".join(lines) + + def _apply_model_env(settings: AgentRuntimeSettings) -> str: for key, value in build_claude_agent_env(settings).items(): os.environ[key] = value @@ -135,7 +183,10 @@ async def _stream_with_claude_sdk( ) -> AsyncIterator[dict[str, Any]]: from claude_agent_sdk import ClaudeAgentOptions, query - system_prompt = _build_system_prompt(agent_configuration) + system_prompt = _build_system_prompt( + agent_configuration, + workspace_appendix=_build_workspace_prompt_appendix(settings), + ) history_lines = [ f"{entry.get('role', 'unknown')}: {entry.get('content', '')}" for entry in message_history[-6:] @@ -155,12 +206,15 @@ async def _stream_with_claude_sdk( } sdk_env = _build_sdk_env(settings) + workspace_dir = settings.workspace_dir.strip() or "/workspace" options = ClaudeAgentOptions( system_prompt=system_prompt, env=sdk_env, permission_mode="bypassPermissions", max_turns=8, setting_sources=[], + cwd=workspace_dir, + add_dirs=[workspace_dir], ) reply: str | None = None diff --git a/src/agent-runtime/tests/test_executor.py b/src/agent-runtime/tests/test_executor.py index 0aa472fd0..865513b8c 100644 --- a/src/agent-runtime/tests/test_executor.py +++ b/src/agent-runtime/tests/test_executor.py @@ -2,12 +2,43 @@ from __future__ import annotations +from pathlib import Path + import pytest -from kartograph_agent_runtime.executor import stream_turn_events +from kartograph_agent_runtime.executor import ( + _build_system_prompt, + _build_workspace_prompt_appendix, + stream_turn_events, +) from kartograph_agent_runtime.settings import AgentRuntimeSettings +def test_build_workspace_prompt_appendix_lists_materialized_repository_files( + tmp_path: Path, +) -> None: + package_root = tmp_path / "repository-files" / "pkg-1" / "pkg" / "api" + package_root.mkdir(parents=True) + (package_root / "adapter_status_types_test.go").write_text("package api\n", encoding="utf-8") + + appendix = _build_workspace_prompt_appendix( + AgentRuntimeSettings(KARTOGRAPH_WORKSPACE_DIR=str(tmp_path)) + ) + + assert "repository-files/<job_package_id>/" in appendix + assert "pkg/api/adapter_status_types_test.go" in appendix + + +def test_build_system_prompt_includes_workspace_appendix() -> None: + prompt = _build_system_prompt( + {"system_prompt": "Base prompt"}, + workspace_appendix="## Session workspace\nFiles here", + ) + + assert "Base prompt" in prompt + assert "Files here" in prompt + + @pytest.mark.asyncio async def test_stream_turn_events_without_api_key_returns_done_reply( monkeypatch: pytest.MonkeyPatch, diff --git a/src/api/extraction/application/sticky_session_materialization.py b/src/api/extraction/application/sticky_session_materialization.py new file mode 100644 index 000000000..f83d7d737 --- /dev/null +++ b/src/api/extraction/application/sticky_session_materialization.py @@ -0,0 +1,25 @@ +"""Helpers for deciding when sticky sessions should load JobPackage material.""" + +from __future__ import annotations + +from extraction.application.job_package_gate import JobPackageGateDecision +from extraction.domain.value_objects import ( + IngestionReadinessSnapshot, + SessionJobPackagePhase, +) + + +def should_materialize_job_packages( + *, + readiness: IngestionReadinessSnapshot, + gate: JobPackageGateDecision, +) -> bool: + """Return whether prepared JobPackage archives should be loaded into the workspace. + + UI-mode gates control whether chat must *wait* for JobPackage readiness. + When prepared packages exist for the knowledge graph, materialize them even + in modes that do not require the gate (e.g. Initial Schema Design). + """ + if readiness.prepared_source_count > 0: + return True + return gate.phase != SessionJobPackagePhase.NOT_REQUIRED diff --git a/src/api/extraction/application/sticky_session_runtime_service.py b/src/api/extraction/application/sticky_session_runtime_service.py index f1ddb6de8..8396c1594 100644 --- a/src/api/extraction/application/sticky_session_runtime_service.py +++ b/src/api/extraction/application/sticky_session_runtime_service.py @@ -9,6 +9,7 @@ from extraction.application.agent_session_service import ExtractionAgentSessionService from extraction.application.job_package_gate import resolve_job_package_gate +from extraction.application.sticky_session_materialization import should_materialize_job_packages from extraction.application.skill_resolution_service import ExtractionSkillResolutionService from extraction.domain.entities.agent_session import ExtractionAgentSession from extraction.domain.value_objects import ( @@ -96,6 +97,20 @@ async def ensure_runtime_for_chat( mode=mode.value, ) if lease is not None: + readiness = await self._ingestion_readiness_reader.read_for_knowledge_graph( + knowledge_graph_id=knowledge_graph_id, + ) + gate = resolve_job_package_gate(ui_mode=ui_mode, readiness=readiness) + if self._runtime_backend == "container": + await self._bootstrap_builder.build( + tenant_id=tenant_id, + knowledge_graph_id=knowledge_graph_id, + session_id=session.id, + include_job_packages=should_materialize_job_packages( + readiness=readiness, + gate=gate, + ), + ) session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="ready") await self._session_service.save_session(session) return @@ -207,7 +222,10 @@ async def _stream_prepare_runtime( tenant_id=tenant_id, knowledge_graph_id=knowledge_graph_id, session_id=session.id, - include_job_packages=gate.phase != SessionJobPackagePhase.NOT_REQUIRED, + include_job_packages=should_materialize_job_packages( + readiness=readiness, + gate=gate, + ), ) yield { "type": "thinking", diff --git a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py index 1fe5b5db3..0fd3f9b66 100644 --- a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py +++ b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py @@ -33,7 +33,11 @@ def prepare( ingestion_context_dir.mkdir(parents=True, exist_ok=True) repository_files_dir.mkdir(parents=True, exist_ok=True) - discovered = job_package_ids or self._discover_job_package_ids() + discovered = ( + self._discover_job_package_ids() + if job_package_ids is None + else job_package_ids + ) for package_id in discovered: archive_path = self._job_package_work_dir / JobPackageId(value=package_id).archive_name() if not archive_path.exists(): diff --git a/src/api/tests/unit/extraction/application/test_sticky_session_materialization.py b/src/api/tests/unit/extraction/application/test_sticky_session_materialization.py new file mode 100644 index 000000000..7dc65580a --- /dev/null +++ b/src/api/tests/unit/extraction/application/test_sticky_session_materialization.py @@ -0,0 +1,40 @@ +"""Unit tests for sticky session JobPackage materialization policy.""" + +from __future__ import annotations + +from extraction.application.job_package_gate import resolve_job_package_gate +from extraction.application.sticky_session_materialization import should_materialize_job_packages +from extraction.domain.value_objects import ( + GraphManagementUiMode, + IngestionReadinessSnapshot, +) + + +def test_schema_design_materializes_when_prepared_sources_exist() -> None: + readiness = IngestionReadinessSnapshot(data_source_count=1, prepared_source_count=1) + gate = resolve_job_package_gate( + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + readiness=readiness, + ) + + assert should_materialize_job_packages(readiness=readiness, gate=gate) is True + + +def test_schema_design_skips_materialization_without_prepared_sources() -> None: + readiness = IngestionReadinessSnapshot(data_source_count=0, prepared_source_count=0) + gate = resolve_job_package_gate( + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + readiness=readiness, + ) + + assert should_materialize_job_packages(readiness=readiness, gate=gate) is False + + +def test_extraction_jobs_materializes_when_gate_ready() -> None: + readiness = IngestionReadinessSnapshot(data_source_count=2, prepared_source_count=2) + gate = resolve_job_package_gate( + ui_mode=GraphManagementUiMode.EXTRACTION_JOBS, + readiness=readiness, + ) + + assert should_materialize_job_packages(readiness=readiness, gate=gate) is True diff --git a/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py index 337a5919c..46fd32977 100644 --- a/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py +++ b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py @@ -174,19 +174,34 @@ async def test_ensure_runtime_for_chat_reprepares_when_persisted_runtime_is_inac assert sticky.try_resolve_active_lease(session_id=session.id) is not None +class _RecordingBootstrapBuilder: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + async def build(self, **kwargs): + self.calls.append(kwargs) + return None + + +class _PreparedIngestionReadinessReader: + async def read_for_knowledge_graph(self, *, knowledge_graph_id: str): + return IngestionReadinessSnapshot(data_source_count=1, prepared_source_count=1) + + @pytest.mark.asyncio async def test_ensure_runtime_for_chat_reuses_running_container_without_reprepare() -> None: repo = _InMemoryAgentSessionRepository() session_service = ExtractionAgentSessionService(repository=repo) sticky = InMemoryStickySessionRuntimeManager() + bootstrap = _RecordingBootstrapBuilder() service = StickySessionRuntimeService( session_service=session_service, skill_resolution_service=_StaticSkillResolutionService(), - ingestion_readiness_reader=_StaticIngestionReadinessReader(), + ingestion_readiness_reader=_PreparedIngestionReadinessReader(), sticky_runtime_manager=sticky, - bootstrap_builder=_StaticBootstrapBuilder(), + bootstrap_builder=bootstrap, health_checker=_InstantHealthChecker(), - runtime_backend="memory", + runtime_backend="container", sticky_health_timeout_seconds=5.0, ) session = await session_service.get_or_create_active_session( @@ -215,3 +230,11 @@ async def test_ensure_runtime_for_chat_reuses_running_container_without_reprepar assert events == [] assert session.runtime_context["sticky_runtime"]["phase"] == "ready" + assert bootstrap.calls == [ + { + "tenant_id": "tenant-1", + "knowledge_graph_id": "kg-1", + "session_id": session.id, + "include_job_packages": True, + } + ] diff --git a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py index e6373bb60..b9fbf1acb 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py +++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py @@ -57,3 +57,17 @@ def test_materializer_extracts_job_package_into_session_workspace(tmp_path: Path repo_file = session_root / "repository-files" / package_id / "README.md" assert repo_file.read_text(encoding="utf-8") == "# hello\n" + + +def test_materializer_does_not_discover_archives_when_package_ids_empty(tmp_path: Path) -> None: + package_id = "01JTESTPACK0000000000000001" + _build_package(tmp_path, package_id) + materializer = StickySessionWorkdirMaterializer(job_package_work_dir=tmp_path) + + session_root = materializer.prepare( + session_id="session-2", + knowledge_graph_id="kg-1", + job_package_ids=(), + ) + + assert not any((session_root / "repository-files").iterdir()) From 4dde3a780978b1556b0de7e195c6acd30c1cc3f1 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 18:42:03 -0400 Subject: [PATCH 70/80] fix(ingestion): report total branch files instead of changeset size Incremental prepares were overwriting last_prepared_file_count with the number of changed files, so the data sources table showed the wrong "Files on branch" value after subsequent prepares. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../application/services/ingestion_service.py | 1 + .../ingestion/application/value_objects.py | 1 + .../infrastructure/adapters/github.py | 24 ++++++ .../ingestion/infrastructure/event_handler.py | 2 +- src/api/ingestion/ports/adapters.py | 3 + .../application/test_ingestion_service.py | 2 + .../adapters/test_github_adapter.py | 75 +++++++++++++++++++ .../test_ingestion_event_handler.py | 3 +- .../tests/unit/management/test_data_source.py | 7 ++ 9 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/api/ingestion/application/services/ingestion_service.py b/src/api/ingestion/application/services/ingestion_service.py index 293554c78..3abfd9472 100644 --- a/src/api/ingestion/application/services/ingestion_service.py +++ b/src/api/ingestion/application/services/ingestion_service.py @@ -145,6 +145,7 @@ async def run( return IngestionRunResult( job_package_id=builder._package_id, entry_count=len(result.changeset_entries), + branch_file_count=result.branch_file_count, prepared_commit_sha=( str(prepared_commit_sha) if prepared_commit_sha is not None else None ), diff --git a/src/api/ingestion/application/value_objects.py b/src/api/ingestion/application/value_objects.py index 5aa28ec68..819dd671e 100644 --- a/src/api/ingestion/application/value_objects.py +++ b/src/api/ingestion/application/value_objects.py @@ -13,4 +13,5 @@ class IngestionRunResult: job_package_id: JobPackageId entry_count: int + branch_file_count: int | None prepared_commit_sha: str | None diff --git a/src/api/ingestion/infrastructure/adapters/github.py b/src/api/ingestion/infrastructure/adapters/github.py index ce2059cd3..03d6e5204 100644 --- a/src/api/ingestion/infrastructure/adapters/github.py +++ b/src/api/ingestion/infrastructure/adapters/github.py @@ -183,12 +183,16 @@ async def extract( files_to_fetch = await self._get_all_tree_blobs( client, headers, owner, repo, head_sha ) + branch_file_count = len(files_to_fetch) else: assert checkpoint is not None # narrowed above base_sha = checkpoint.data[_COMMIT_SHA_KEY] files_to_fetch = await self._get_changed_files( client, headers, owner, repo, base_sha, head_sha ) + branch_file_count = await self._count_tree_blobs( + client, headers, owner, repo, head_sha + ) # Step 3: Fetch content for each file changeset_entries, content_blobs = await self._fetch_file_contents( @@ -209,6 +213,7 @@ async def extract( changeset_entries=changeset_entries, content_blobs=content_blobs, new_checkpoint=new_checkpoint, + branch_file_count=branch_file_count, ) # ------------------------------------------------------------------ @@ -288,6 +293,25 @@ async def _get_all_tree_blobs( ) return result + async def _count_tree_blobs( + self, + client: httpx.AsyncClient, + headers: dict[str, str], + owner: str, + repo: str, + tree_sha: str, + ) -> int: + """Count blob entries in the repository tree at a commit.""" + url = ( + f"{_GITHUB_API_BASE}/repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1" + ) + response = await client.get(url, headers=headers) + response.raise_for_status() + tree_data: dict[str, Any] = response.json() + return sum( + 1 for item in tree_data.get("tree", []) if item.get("type") == "blob" + ) + async def _get_changed_files( self, client: httpx.AsyncClient, diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index 052f6b9bc..ceec6fd32 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -173,7 +173,7 @@ async def handle( "knowledge_graph_id": knowledge_graph_id, "job_package_id": str(ingestion_result.job_package_id), "prepared_commit_sha": ingestion_result.prepared_commit_sha, - "prepared_file_count": ingestion_result.entry_count, + "prepared_file_count": ingestion_result.branch_file_count, "occurred_at": now.isoformat(), }, occurred_at=now, diff --git a/src/api/ingestion/ports/adapters.py b/src/api/ingestion/ports/adapters.py index 0553b2ee5..cec85b6ed 100644 --- a/src/api/ingestion/ports/adapters.py +++ b/src/api/ingestion/ports/adapters.py @@ -40,11 +40,14 @@ class ExtractionResult: new_checkpoint: Opaque adapter-specific state capturing the extraction position (e.g., the current commit SHA for GitHub). Must be persisted by the caller so the next incremental run starts here. + branch_file_count: Total blob files on the source branch at the + extraction HEAD commit, when the adapter can determine it. """ changeset_entries: list[ChangesetEntry] content_blobs: dict[str, bytes] new_checkpoint: AdapterCheckpoint + branch_file_count: int | None = None @runtime_checkable diff --git a/src/api/tests/unit/ingestion/application/test_ingestion_service.py b/src/api/tests/unit/ingestion/application/test_ingestion_service.py index 3a06cf64f..8da91a1d9 100644 --- a/src/api/tests/unit/ingestion/application/test_ingestion_service.py +++ b/src/api/tests/unit/ingestion/application/test_ingestion_service.py @@ -45,6 +45,7 @@ def _make_extraction_result( changeset_entries=[entry], content_blobs={content_ref.hex_digest: content}, new_checkpoint=checkpoint, + branch_file_count=1, ) @@ -108,6 +109,7 @@ async def test_run_returns_job_package_id(self): assert isinstance(result, IngestionRunResult) assert isinstance(result.job_package_id, JobPackageId) assert result.entry_count == 1 + assert result.branch_file_count == 1 assert result.prepared_commit_sha == "deadbeef" async def test_run_creates_zip_archive(self): diff --git a/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py b/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py index 08ec50525..5f90bca74 100644 --- a/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py +++ b/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py @@ -79,6 +79,14 @@ def _tree_response( return {"sha": HEAD_SHA, "tree": files, "truncated": False} +def _head_tree_response(files: list[dict] | None = None) -> dict: + """Default tree at HEAD for incremental branch file count.""" + return _tree_response(files) + + +HEAD_TREE_PATH = f"/git/trees/{HEAD_SHA}" + + def _compare_response( changed_files: list[dict] | None = None, ) -> dict: @@ -179,6 +187,26 @@ def incremental_transport() -> FakeGitHubTransport: { # Branch tip "/branches/main": _branch_response(HEAD_SHA), + # Tree at HEAD for branch file count (3 blobs on branch) + f"/git/trees/{HEAD_SHA}": _tree_response( + [ + { + "path": "README.md", + "type": "blob", + "sha": BLOB_SHA_README, + }, + { + "path": "src/main.py", + "type": "blob", + "sha": BLOB_SHA_MAIN, + }, + { + "path": "src/utils.py", + "type": "blob", + "sha": BLOB_SHA_UTILS, + }, + ] + ), # Compare endpoint f"/compare/{BASE_SHA}...{HEAD_SHA}": _compare_response( [ @@ -374,6 +402,29 @@ async def test_incremental_returns_only_changed_files( assert len(result.changeset_entries) == 1 assert result.changeset_entries[0].path == "src/utils.py" + assert result.branch_file_count == 3 + + @pytest.mark.asyncio + async def test_incremental_reports_branch_file_count_separate_from_changeset( + self, connection_config, credentials, incremental_transport + ): + """Branch file count reflects total blobs, not just changed files.""" + client = httpx.AsyncClient(transport=incremental_transport) + adapter = GitHubAdapter(http_client=client) + + checkpoint = AdapterCheckpoint( + schema_version="1.0.0", data={"commit_sha": BASE_SHA} + ) + + result = await adapter.extract( + connection_config=connection_config, + credentials=credentials, + checkpoint=checkpoint, + sync_mode=SyncMode.INCREMENTAL, + ) + + assert len(result.changeset_entries) == 1 + assert result.branch_file_count == 3 @pytest.mark.asyncio async def test_incremental_maps_added_status_to_add_operation( @@ -405,6 +456,7 @@ async def test_incremental_maps_modified_status_to_modify_operation( transport = FakeGitHubTransport( { "/branches/main": _branch_response(HEAD_SHA), + HEAD_TREE_PATH: _head_tree_response(), f"/compare/{BASE_SHA}...{HEAD_SHA}": _compare_response( [ { @@ -442,6 +494,7 @@ async def test_incremental_maps_renamed_status_to_modify_operation( transport = FakeGitHubTransport( { "/branches/main": _branch_response(HEAD_SHA), + HEAD_TREE_PATH: _head_tree_response(), f"/compare/{BASE_SHA}...{HEAD_SHA}": _compare_response( [ { @@ -482,6 +535,7 @@ async def test_incremental_ignores_removed_files( transport = FakeGitHubTransport( { "/branches/main": _branch_response(HEAD_SHA), + HEAD_TREE_PATH: _head_tree_response(), f"/compare/{BASE_SHA}...{HEAD_SHA}": _compare_response( [ { @@ -518,6 +572,7 @@ async def test_incremental_no_changes_returns_empty_result( transport = FakeGitHubTransport( { "/branches/main": _branch_response(HEAD_SHA), + HEAD_TREE_PATH: _head_tree_response(), f"/compare/{BASE_SHA}...{HEAD_SHA}": _compare_response([]), } ) @@ -722,6 +777,26 @@ async def handle_async_request( url_path = request.url.path if url_path.endswith("/branches/main"): data: dict = _branch_response(HEAD_SHA) + elif f"/git/trees/{HEAD_SHA}" in url_path: + data = _head_tree_response( + [ + { + "path": "README.md", + "type": "blob", + "sha": BLOB_SHA_README, + }, + { + "path": "src/main.py", + "type": "blob", + "sha": BLOB_SHA_MAIN, + }, + { + "path": "src/utils.py", + "type": "blob", + "sha": BLOB_SHA_UTILS, + }, + ] + ) elif f"/compare/{BASE_SHA}...{HEAD_SHA}" in url_path: data = _compare_response( [ diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index ce23acb01..07ac2d446 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -86,6 +86,7 @@ async def run( return IngestionRunResult( job_package_id=JobPackageId(value="01HRZZZZZZZZZZZZZZZZZZZZZ0"), entry_count=42, + branch_file_count=99, prepared_commit_sha="abc123def456", ) @@ -258,7 +259,7 @@ async def test_emits_ingestion_prepared_when_ingest_only( assert event["event_type"] == "IngestionPrepared" assert event["payload"]["job_package_id"] is not None assert event["payload"]["prepared_commit_sha"] == "abc123def456" - assert event["payload"]["prepared_file_count"] == 42 + assert event["payload"]["prepared_file_count"] == 99 async def test_no_changes_ingest_only_emits_ingestion_prepared( self, diff --git a/src/api/tests/unit/management/test_data_source.py b/src/api/tests/unit/management/test_data_source.py index 49c20ae0d..184b2be45 100644 --- a/src/api/tests/unit/management/test_data_source.py +++ b/src/api/tests/unit/management/test_data_source.py @@ -465,6 +465,13 @@ def test_record_ingestion_prepared_preserves_file_count_when_none(self): assert ds.last_prepared_commit == "abc123" assert ds.last_prepared_file_count == 10 + def test_record_ingestion_prepared_updates_branch_file_count_on_incremental(self): + """Incremental prepares must store total branch files, not changeset size.""" + ds = self._create_ds() + ds.last_prepared_file_count = 120 + ds.record_ingestion_prepared(prepared_commit="def456", prepared_file_count=124) + assert ds.last_prepared_file_count == 124 + class TestDataSourceMarkForDeletion: """Tests for DataSource.mark_for_deletion() method.""" From 8df2591d7cf9330e00eb93688360fee6c8f1736a Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 20:40:27 -0400 Subject: [PATCH 71/80] fix(ui): keep data sources table visible during sync polling Background refreshes no longer toggle the page-level loading gate, so prepare polling updates status in place with a subtle updating indicator. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../[kgId]/data-sources/index.vue | 29 +++++++++++++++---- .../app/tests/kg-data-sources-phase1.test.ts | 6 ++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue index 728feaa65..162f0afb5 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -127,6 +127,7 @@ const { apiFetch } = useApiClient() const kgName = ref('') const dataSources = ref<DataSourceItem[]>([]) const loading = ref(false) +const refreshing = ref(false) const expandedDiffLists = ref<Record<string, boolean>>({}) const checkingAllCommits = ref(false) const preparingAll = ref(false) @@ -187,7 +188,7 @@ function stopPolling() { function startPolling() { if (pollInterval.value !== null) return pollInterval.value = setInterval(async () => { - await loadDataSources() + await loadDataSources({ silent: true }) if (!hasAnyActiveSync(dataSources.value)) { stopPolling() } @@ -294,9 +295,14 @@ async function loadKnowledgeGraph() { } } -async function loadDataSources() { +async function loadDataSources(options: { silent?: boolean } = {}) { if (!hasTenant.value) return - loading.value = true + const silent = options.silent ?? dataSources.value.length > 0 + if (silent) { + refreshing.value = true + } else { + loading.value = true + } try { const sources = await apiFetch<DataSourceItem[]>( `/management/knowledge-graphs/${kgId.value}/data-sources`, @@ -319,9 +325,15 @@ async function loadDataSources() { } dataSources.value = sources } catch { - dataSources.value = [] + if (!silent) { + dataSources.value = [] + } } finally { - loading.value = false + if (silent) { + refreshing.value = false + } else { + loading.value = false + } } } @@ -630,6 +642,13 @@ watch(tenantVersion, async () => { <CardTitle class="flex items-center gap-2 text-base"> <GitBranch class="size-4 text-primary" /> Data sources overview + <span + v-if="refreshing" + class="inline-flex items-center gap-1 text-xs font-normal text-muted-foreground" + > + <Loader2 class="size-3 animate-spin" /> + Updating… + </span> </CardTitle> </div> <div class="flex flex-wrap gap-2"> diff --git a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts index a290ed065..1254800df 100644 --- a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts +++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts @@ -58,6 +58,12 @@ describe('KG data sources phase1 layout', () => { expect(phase1Vue).toContain('formatPreparedFileCount') }) + it('refreshes data sources silently while polling', () => { + expect(phase1Vue).toContain('loadDataSources({ silent: true })') + expect(phase1Vue).toContain('refreshing') + expect(phase1Vue).toContain('Updating…') + }) + it('shows unpulled commit columns', () => { expect(phase1Vue).toContain('Newest unpulled') expect(phase1Vue).toContain('Last extraction baseline') From 85e5716361fd2ffe94b3e087b431c88e895804c5 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 20:42:10 -0400 Subject: [PATCH 72/80] fix(ui): constrain KG manage workspace to max-w-7xl Graph Management and other manage steps no longer stretch edge-to-edge on wide screens, matching the data sources workspace layout. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue | 2 +- src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 87701447d..29fe10dc3 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -1216,7 +1216,7 @@ watch(selectedOpsDataSourceId, () => { </script> <template> - <div class="space-y-6"> + <div class="mx-auto max-w-7xl space-y-6"> <template v-if="showOverview"> <NuxtLink to="/knowledge-graphs" diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index c4c935fc8..a0cdc8aac 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -388,6 +388,10 @@ describe('KG-MANAGE-006 - graph management conversation-first layout', () => { expect(manageWorkspaceVue).toContain('graph-management-controls') }) + it('uses a centered max-width page container like other KG workspace steps', () => { + expect(manageWorkspaceVue).toContain('mx-auto max-w-7xl') + }) + it('uses one shared session endpoint across UI mode changes', () => { expect(manageWorkspaceVue).toContain('sharedSessionMode') expect(manageWorkspaceVue).toContain('/sessions/${sharedSessionMode.value}/active') From a5daa9cc5bd14004df3180bdcd7a2ab287ee2e99 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 20:46:19 -0400 Subject: [PATCH 73/80] feat(management): detect missing prepared JobPackage archives Expose job_package_available on data source listings, rematerialize when the ZIP is gone, and skip ingest-only no-changes short-circuit without it. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/main.py | 32 ++++++++- .../job_package_archive_reader.py | 36 ++++++++++ .../presentation/data_sources/models.py | 10 ++- .../presentation/data_sources/routes.py | 25 ++++++- .../job_package/archive_availability.py | 19 ++++++ .../job_package/test_archive_availability.py | 47 +++++++++++++ .../unit/test_sessioned_ingestion_handler.py | 68 +++++++++++++++++++ src/dev-ui/app/utils/kgDataSourcesCommits.ts | 14 +++- 8 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 src/api/management/infrastructure/job_package_archive_reader.py create mode 100644 src/api/shared_kernel/job_package/archive_availability.py create mode 100644 src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py diff --git a/src/api/main.py b/src/api/main.py index dc3d05eb1..93be300ea 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -195,6 +195,29 @@ async def _resolve_github_tracked_head_commit( sha = payload.get("commit", {}).get("sha") return str(sha) if sha else None + async def _ingest_only_archive_available( + self, + *, + session: Any, + data_source_id: str, + ) -> bool: + """Return whether a previously prepared JobPackage archive still exists on disk.""" + from management.infrastructure.job_package_archive_reader import ( + SqlJobPackageArchiveReader, + ) + from shared_kernel.job_package.archive_availability import ( + job_package_archive_exists, + ) + + reader = SqlJobPackageArchiveReader(session=session) + package_id = await reader.latest_job_package_id_for_data_source( + data_source_id=data_source_id, + ) + return job_package_archive_exists( + work_dir=_JOB_PACKAGE_WORK_DIR, + job_package_id=package_id, + ) + async def handle(self, event_type: str, payload: dict[str, Any]) -> None: from ingestion.infrastructure.adapters.github import GitHubAdapter from ingestion.application.services.ingestion_service import IngestionService @@ -283,7 +306,14 @@ async def handle(self, event_type: str, payload: dict[str, Any]) -> None: and baseline_commit and baseline_commit == tracked_head ): - enriched_payload["no_changes_detected"] = True + if pipeline_mode == "ingest_only": + if await self._ingest_only_archive_available( + session=session, + data_source_id=data_source_id, + ): + enriched_payload["no_changes_detected"] = True + else: + enriched_payload["no_changes_detected"] = True await ingestion_handler.handle( event_type, diff --git a/src/api/management/infrastructure/job_package_archive_reader.py b/src/api/management/infrastructure/job_package_archive_reader.py new file mode 100644 index 000000000..00e4d678d --- /dev/null +++ b/src/api/management/infrastructure/job_package_archive_reader.py @@ -0,0 +1,36 @@ +"""Read latest JobPackage identifiers for data source archive availability checks.""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +class SqlJobPackageArchiveReader: + """Resolve the latest JobPackage id emitted for one data source.""" + + def __init__(self, *, session: AsyncSession) -> None: + self._session = session + + async def latest_job_package_id_for_data_source( + self, *, data_source_id: str + ) -> str | None: + result = await self._session.execute( + text( + """ + SELECT payload->>'job_package_id' AS job_package_id + FROM outbox + WHERE event_type IN ('IngestionPrepared', 'JobPackageProduced') + AND payload->>'data_source_id' = :data_source_id + AND payload->>'job_package_id' IS NOT NULL + ORDER BY occurred_at DESC + LIMIT 1 + """ + ), + {"data_source_id": data_source_id}, + ) + row = result.one_or_none() + if row is None or row.job_package_id is None: + return None + package_id = str(row.job_package_id).strip() + return package_id or None diff --git a/src/api/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py index ec4aca13a..699fbbf64 100644 --- a/src/api/management/presentation/data_sources/models.py +++ b/src/api/management/presentation/data_sources/models.py @@ -207,7 +207,8 @@ class DataSourceResponse(BaseModel): None, description="Commit SHA captured during the last ingest-only prepare" ) last_prepared_file_count: int | None = Field( - None, description="Number of files in the JobPackage from the last prepare" + None, + description="Total files on the tracked branch at the last prepare commit", ) ingested_head_commit: str | None = Field( None, @@ -220,6 +221,10 @@ class DataSourceResponse(BaseModel): "null when up to date with branch tip" ), ) + job_package_available: bool | None = Field( + None, + description="Whether the latest prepared JobPackage archive exists on disk", + ) connection_config: dict[str, str] = Field( default_factory=dict, description="Adapter connection configuration (non-secret)", @@ -505,7 +510,8 @@ class DataSourceWithSyncResponse(BaseModel): None, description="Commit SHA captured during the last ingest-only prepare" ) last_prepared_file_count: int | None = Field( - None, description="Number of files in the JobPackage from the last prepare" + None, + description="Total files on the tracked branch at the last prepare commit", ) ingested_head_commit: str | None = Field( None, diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index d063859ee..33ce5aa11 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -2,12 +2,18 @@ from __future__ import annotations +from pathlib import Path from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from extraction.infrastructure.workload_runtime_settings import ( + get_extraction_workload_runtime_settings, +) from iam.application.value_objects import CurrentUser from iam.dependencies.user import get_current_user +from infrastructure.database.dependencies import get_write_session from management.application.services.data_source_service import DataSourceService from management.dependencies.data_source import ( get_data_source_service, @@ -19,8 +25,10 @@ GitCommitReferenceService, ) from management.infrastructure.git_diff_summary_service import GitDiffSummaryService +from management.infrastructure.job_package_archive_reader import SqlJobPackageArchiveReader from management.ports.exceptions import UnauthorizedError from management.ports.repositories import IDataSourceSyncRunRepository +from shared_kernel.job_package.archive_availability import job_package_archive_exists from management.presentation.data_sources.models import ( CreateDataSourceRequest, DataSourceDiffSummaryResponse, @@ -263,6 +271,7 @@ async def list_data_sources( kg_id: str, current_user: Annotated[CurrentUser, Depends(get_current_user)], service: Annotated[DataSourceService, Depends(get_data_source_service)], + session: Annotated[AsyncSession, Depends(get_write_session)], ) -> list[DataSourceResponse]: """List all data sources for a knowledge graph. @@ -272,6 +281,7 @@ async def list_data_sources( kg_id: Knowledge Graph ID to list data sources for current_user: Current authenticated user with tenant context service: Data source service for orchestration + session: Database session for JobPackage archive lookups Returns: List of DataSourceResponse objects for the knowledge graph @@ -285,7 +295,20 @@ async def list_data_sources( user_id=current_user.user_id.value, kg_id=kg_id, ) - return [DataSourceResponse.from_domain(ds) for ds in data_sources] + archive_reader = SqlJobPackageArchiveReader(session=session) + work_dir = get_extraction_workload_runtime_settings().job_package_work_dir + responses: list[DataSourceResponse] = [] + for ds in data_sources: + response = DataSourceResponse.from_domain(ds) + package_id = await archive_reader.latest_job_package_id_for_data_source( + data_source_id=ds.id.value, + ) + response.job_package_available = job_package_archive_exists( + work_dir=Path(work_dir), + job_package_id=package_id, + ) + responses.append(response) + return responses except UnauthorizedError: raise HTTPException( diff --git a/src/api/shared_kernel/job_package/archive_availability.py b/src/api/shared_kernel/job_package/archive_availability.py new file mode 100644 index 000000000..1f8d484da --- /dev/null +++ b/src/api/shared_kernel/job_package/archive_availability.py @@ -0,0 +1,19 @@ +"""Helpers for checking JobPackage archive presence on disk.""" + +from __future__ import annotations + +from pathlib import Path + +from shared_kernel.job_package.value_objects import JobPackageId + + +def job_package_archive_path(*, work_dir: Path, job_package_id: str) -> Path: + """Return the expected on-disk path for one JobPackage archive.""" + return work_dir / JobPackageId(value=job_package_id).archive_name() + + +def job_package_archive_exists(*, work_dir: Path, job_package_id: str | None) -> bool: + """Return whether the JobPackage ZIP archive exists locally.""" + if not job_package_id or not job_package_id.strip(): + return False + return job_package_archive_path(work_dir=work_dir, job_package_id=job_package_id).is_file() diff --git a/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py b/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py new file mode 100644 index 000000000..60d82e745 --- /dev/null +++ b/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py @@ -0,0 +1,47 @@ +"""Unit tests for JobPackage archive availability helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from shared_kernel.job_package.archive_availability import job_package_archive_exists +from shared_kernel.job_package.builder import JobPackageBuilder +from shared_kernel.job_package.value_objects import ( + AdapterCheckpoint, + ChangeOperation, + ChangesetEntry, + ContentRef, + JobPackageId, + SyncMode, +) + + +def test_job_package_archive_exists_when_file_present(tmp_path: Path) -> None: + package_id = "01JTESTPACK0000000000000099" + content_bytes = b"# hello\n" + builder = JobPackageBuilder( + data_source_id="ds-1", + knowledge_graph_id="kg-1", + sync_mode=SyncMode.FULL_REFRESH, + package_id=JobPackageId(value=package_id), + ) + ref = builder.add_content(content_bytes) + builder.add_changeset_entry( + ChangesetEntry( + operation=ChangeOperation.ADD, + id="file-1", + type="io.kartograph.change.file", + path="README.md", + content_ref=ref, + content_type="text/markdown", + metadata={}, + ) + ) + builder.set_checkpoint(AdapterCheckpoint(schema_version="1.0.0", data={})) + builder.build(tmp_path) + + assert job_package_archive_exists(work_dir=tmp_path, job_package_id=package_id) is True + + +def test_job_package_archive_exists_when_file_missing(tmp_path: Path) -> None: + assert job_package_archive_exists(work_dir=tmp_path, job_package_id="missing") is False diff --git a/src/api/tests/unit/test_sessioned_ingestion_handler.py b/src/api/tests/unit/test_sessioned_ingestion_handler.py index 661a14df6..79a6715ea 100644 --- a/src/api/tests/unit/test_sessioned_ingestion_handler.py +++ b/src/api/tests/unit/test_sessioned_ingestion_handler.py @@ -189,6 +189,7 @@ async def test_sessioned_ingestion_handler_uses_last_prepared_for_ingest_only(): session_factory = _make_session_factory(session) handler = _SessionedIngestionEventHandler(session_factory=session_factory) handler._resolve_github_tracked_head_commit = AsyncMock(return_value="prepared123") # type: ignore[attr-defined] + handler._ingest_only_archive_available = AsyncMock(return_value=True) # type: ignore[attr-defined] outbox_repo = MagicMock() ds_repo = MagicMock() @@ -245,3 +246,70 @@ async def test_sessioned_ingestion_handler_uses_last_prepared_for_ingest_only(): assert call_payload["baseline_commit"] == "prepared123" assert call_payload["no_changes_detected"] is True + +@pytest.mark.asyncio +async def test_sessioned_ingestion_handler_runs_ingest_only_when_archive_missing(): + """ingest_only at branch head should still run when the JobPackage ZIP was lost.""" + from main import _SessionedIngestionEventHandler + + session = AsyncMock() + session_factory = _make_session_factory(session) + handler = _SessionedIngestionEventHandler(session_factory=session_factory) + handler._resolve_github_tracked_head_commit = AsyncMock(return_value="prepared123") # type: ignore[attr-defined] + handler._ingest_only_archive_available = AsyncMock(return_value=False) # type: ignore[attr-defined] + + outbox_repo = MagicMock() + ds_repo = MagicMock() + secret_store = MagicMock() + ingestion_handler = MagicMock() + ingestion_handler.handle = AsyncMock() + ingestion_service = MagicMock() + + ds = _make_data_source() + ds.last_prepared_commit = "prepared123" + ds_repo.get_by_id = AsyncMock(return_value=ds) + ds_repo.save = AsyncMock() + secret_store.retrieve = AsyncMock(return_value={"token": "tok"}) + + payload = { + "sync_run_id": "run-004", + "data_source_id": ds.id.value, + "knowledge_graph_id": ds.knowledge_graph_id, + "tenant_id": ds.tenant_id, + "adapter_type": "github", + "connection_config": ds.connection_config, + "credentials_path": ds.credentials_path, + "pipeline_mode": "ingest_only", + } + + management_settings = MagicMock() + management_settings.encryption_key.get_secret_value.return_value = ( + "WlAwWU83a2hSODl2SVY4MHBzQWpwaDBSUHhOU3NfQ3R6aXpvNTJfNE5odz0=" + ) + + with ( + patch("infrastructure.outbox.repository.OutboxRepository", return_value=outbox_repo), + patch( + "management.infrastructure.repositories.data_source_repository.DataSourceRepository", + return_value=ds_repo, + ), + patch( + "management.infrastructure.repositories.fernet_secret_store.FernetSecretStore", + return_value=secret_store, + ), + patch( + "ingestion.application.services.ingestion_service.IngestionService", + return_value=ingestion_service, + ), + patch( + "ingestion.infrastructure.event_handler.IngestionEventHandler", + return_value=ingestion_handler, + ), + patch("main.get_management_settings", return_value=management_settings), + ): + await handler.handle("SyncStarted", payload) + + call_payload = ingestion_handler.handle.call_args.args[1] + assert call_payload["baseline_commit"] == "prepared123" + assert "no_changes_detected" not in call_payload + diff --git a/src/dev-ui/app/utils/kgDataSourcesCommits.ts b/src/dev-ui/app/utils/kgDataSourcesCommits.ts index a5ea69801..a1856bd60 100644 --- a/src/dev-ui/app/utils/kgDataSourcesCommits.ts +++ b/src/dev-ui/app/utils/kgDataSourcesCommits.ts @@ -74,8 +74,18 @@ export function unpulledCommitStatusLabel( return 'new commit on branch (not ingested yet)' } -export function needsIngestionPrepare(ds: Parameters<typeof hasUnpulledCommits>[0]): boolean { - return hasUnpulledCommits(ds) +export function needsJobPackageRematerialize(ds: { + last_prepared_commit?: string | null + job_package_available?: boolean | null +}): boolean { + return Boolean(ds.last_prepared_commit) && ds.job_package_available === false +} + +export function needsIngestionPrepare(ds: Parameters<typeof hasUnpulledCommits>[0] & { + last_prepared_commit?: string | null + job_package_available?: boolean | null +}): boolean { + return hasUnpulledCommits(ds) || needsJobPackageRematerialize(ds) } export function isIngestionPreparedAtHead(ds: Parameters<typeof hasUnpulledCommits>[0]): boolean { From be91d2a388292b002aace5f785d203fd7d8be9ab Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 20:46:43 -0400 Subject: [PATCH 74/80] fix(extraction): reuse healthy sticky runtime and defer chat persistence Skip workspace rematerialization when the container is healthy and JobPackage IDs match, report 503 until the agent workspace is ready, and only save user messages after the assistant turn completes or fails. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../kartograph_agent_runtime/server.py | 19 ++- src/agent-runtime/tests/test_server.py | 36 +++++ .../application/chat_turn_service.py | 6 +- .../sticky_session_runtime_service.py | 47 ++++-- .../infrastructure/sticky_runtime_health.py | 12 ++ .../sticky_session_bootstrap_builder.py | 13 ++ .../sticky_session_workdir_materializer.py | 14 +- .../extraction/ports/sticky_runtime_health.py | 4 + .../application/test_chat_turn_service.py | 3 + .../test_sticky_session_runtime_service.py | 151 +++++++++++++++--- ...est_sticky_session_workdir_materializer.py | 20 +++ 11 files changed, 281 insertions(+), 44 deletions(-) create mode 100644 src/agent-runtime/tests/test_server.py diff --git a/src/agent-runtime/kartograph_agent_runtime/server.py b/src/agent-runtime/kartograph_agent_runtime/server.py index 87b89a70e..7a1df58ed 100644 --- a/src/agent-runtime/kartograph_agent_runtime/server.py +++ b/src/agent-runtime/kartograph_agent_runtime/server.py @@ -7,8 +7,10 @@ from collections.abc import AsyncIterator from typing import Any +from pathlib import Path + from fastapi import FastAPI -from fastapi.responses import StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field from kartograph_agent_runtime.executor import stream_turn_events @@ -27,8 +29,21 @@ class TurnRequest(BaseModel): message_history: list[dict[str, Any]] = Field(default_factory=list) +def _workspace_ready() -> bool: + marker = Path(settings.workspace_dir) / "knowledge-graph-id" + return marker.is_file() + + @app.get("/health") -async def health() -> dict[str, str]: +async def health(): + if not _workspace_ready(): + return JSONResponse( + status_code=503, + content={ + "status": "workspace_unavailable", + "session_id": settings.session_id, + }, + ) return {"status": "ok", "session_id": settings.session_id} diff --git a/src/agent-runtime/tests/test_server.py b/src/agent-runtime/tests/test_server.py new file mode 100644 index 000000000..131606439 --- /dev/null +++ b/src/agent-runtime/tests/test_server.py @@ -0,0 +1,36 @@ +"""Unit tests for agent runtime HTTP health endpoints.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from kartograph_agent_runtime import server + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(server.settings, "workspace_dir", str(tmp_path)) + monkeypatch.setattr(server.settings, "session_id", "session-test") + return TestClient(server.app) + + +def test_health_returns_ok_when_workspace_marker_present( + client: TestClient, + tmp_path: Path, +) -> None: + (tmp_path / "knowledge-graph-id").write_text("kg-1", encoding="utf-8") + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_health_returns_unavailable_when_workspace_marker_missing(client: TestClient) -> None: + response = client.get("/health") + + assert response.status_code == 503 + assert response.json()["status"] == "workspace_unavailable" diff --git a/src/api/extraction/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py index 8046f47a9..84220026b 100644 --- a/src/api/extraction/application/chat_turn_service.py +++ b/src/api/extraction/application/chat_turn_service.py @@ -125,10 +125,6 @@ async def stream_chat_turn( ], } - session.message_history.append({"role": "user", "content": trimmed}) - session.updated_at = datetime.now(UTC) - await self._session_service.save_session(session) - assistant_reply: str | None = None stream_failed = False async for event in self._chat_agent.stream_turn( @@ -150,11 +146,13 @@ async def stream_chat_turn( yield event if assistant_reply: + session.message_history.append({"role": "user", "content": trimmed}) session.message_history.append({"role": "assistant", "content": assistant_reply}) session.updated_at = datetime.now(UTC) session.runtime_context.pop("activity_lines", None) await self._session_service.save_session(session) elif stream_failed: + session.message_history.append({"role": "user", "content": trimmed}) session.updated_at = datetime.now(UTC) await self._session_service.save_session(session) else: diff --git a/src/api/extraction/application/sticky_session_runtime_service.py b/src/api/extraction/application/sticky_session_runtime_service.py index 8396c1594..e304089d8 100644 --- a/src/api/extraction/application/sticky_session_runtime_service.py +++ b/src/api/extraction/application/sticky_session_runtime_service.py @@ -97,23 +97,30 @@ async def ensure_runtime_for_chat( mode=mode.value, ) if lease is not None: + runtime_base_url = lease.runtime_base_url or "" readiness = await self._ingestion_readiness_reader.read_for_knowledge_graph( knowledge_graph_id=knowledge_graph_id, ) gate = resolve_job_package_gate(ui_mode=ui_mode, readiness=readiness) - if self._runtime_backend == "container": - await self._bootstrap_builder.build( - tenant_id=tenant_id, - knowledge_graph_id=knowledge_graph_id, - session_id=session.id, - include_job_packages=should_materialize_job_packages( - readiness=readiness, - gate=gate, - ), + include_job_packages = should_materialize_job_packages( + readiness=readiness, + gate=gate, + ) + expected_package_ids = await self._bootstrap_builder.resolve_job_package_ids( + knowledge_graph_id=knowledge_graph_id, + include_job_packages=include_job_packages, + ) + stored_materialization = session.runtime_context.get("workspace_materialization", {}) + stored_package_ids = tuple(stored_materialization.get("job_package_ids") or ()) + if ( + await self._health_checker.is_healthy(runtime_base_url=runtime_base_url) + and stored_package_ids == expected_package_ids + ): + session.runtime_context["sticky_runtime"] = self._lease_context( + lease, phase="ready" ) - session.runtime_context["sticky_runtime"] = self._lease_context(lease, phase="ready") - await self._session_service.save_session(session) - return + await self._session_service.save_session(session) + return async for event in self._stream_prepare_runtime( tenant_id=tenant_id, @@ -218,15 +225,23 @@ async def _stream_prepare_runtime( "Materializing workspace and skills for sticky container", ], } + include_job_packages = should_materialize_job_packages( + readiness=readiness, + gate=gate, + ) + package_ids = await self._bootstrap_builder.resolve_job_package_ids( + knowledge_graph_id=knowledge_graph_id, + include_job_packages=include_job_packages, + ) bootstrap = await self._bootstrap_builder.build( tenant_id=tenant_id, knowledge_graph_id=knowledge_graph_id, session_id=session.id, - include_job_packages=should_materialize_job_packages( - readiness=readiness, - gate=gate, - ), + include_job_packages=include_job_packages, ) + session.runtime_context["workspace_materialization"] = { + "job_package_ids": list(package_ids), + } yield { "type": "thinking", "recent": [ diff --git a/src/api/extraction/infrastructure/sticky_runtime_health.py b/src/api/extraction/infrastructure/sticky_runtime_health.py index 01f99523b..65910ade6 100644 --- a/src/api/extraction/infrastructure/sticky_runtime_health.py +++ b/src/api/extraction/infrastructure/sticky_runtime_health.py @@ -45,3 +45,15 @@ async def wait_until_healthy( raise TimeoutError( f"Sticky session runtime did not become healthy within {int(timeout_seconds)}s" ) + + async def is_healthy(self, *, runtime_base_url: str) -> bool: + """Return whether the sticky runtime currently responds on /health.""" + if runtime_base_url.startswith("memory://"): + return True + url = f"{runtime_base_url.rstrip('/')}/health" + try: + async with httpx.AsyncClient(timeout=self._request_timeout_seconds) as client: + response = await client.get(url) + return response.status_code == 200 + except httpx.HTTPError: + return False diff --git a/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py b/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py index 1777db9e4..c646970c0 100644 --- a/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py +++ b/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py @@ -30,6 +30,19 @@ def __init__( self._workdir_materializer = workdir_materializer self._runtime_settings = runtime_settings or get_extraction_workload_runtime_settings() + async def resolve_job_package_ids( + self, + *, + knowledge_graph_id: str, + include_job_packages: bool, + ) -> tuple[str, ...]: + """Return JobPackage IDs that would be materialized for one session.""" + if not include_job_packages: + return () + return await self._prepared_job_package_reader.list_latest_for_knowledge_graph( + knowledge_graph_id=knowledge_graph_id, + ) + async def build( self, *, diff --git a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py index 0fd3f9b66..74a0ef8c9 100644 --- a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py +++ b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py @@ -11,6 +11,13 @@ from shared_kernel.job_package.value_objects import JobPackageId +def _replace_directory(path: Path) -> None: + """Replace a directory tree without removing its parent mount point.""" + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True, exist_ok=True) + + class StickySessionWorkdirMaterializer: """Materialize JobPackage archives into a session-scoped work directory.""" @@ -26,12 +33,11 @@ def prepare( ) -> Path: """Create or refresh the host work directory for one sticky session.""" session_root = self._job_package_work_dir / "sticky-sessions" / session_id - if session_root.exists(): - shutil.rmtree(session_root) + session_root.mkdir(parents=True, exist_ok=True) ingestion_context_dir = session_root / "ingestion-context" repository_files_dir = session_root / "repository-files" - ingestion_context_dir.mkdir(parents=True, exist_ok=True) - repository_files_dir.mkdir(parents=True, exist_ok=True) + _replace_directory(ingestion_context_dir) + _replace_directory(repository_files_dir) discovered = ( self._discover_job_package_ids() diff --git a/src/api/extraction/ports/sticky_runtime_health.py b/src/api/extraction/ports/sticky_runtime_health.py index be72eaa0f..c23c9d4ac 100644 --- a/src/api/extraction/ports/sticky_runtime_health.py +++ b/src/api/extraction/ports/sticky_runtime_health.py @@ -17,3 +17,7 @@ async def wait_until_healthy( ) -> AsyncIterator[str]: """Yield human-readable progress lines until healthy or timeout.""" ... + + async def is_healthy(self, *, runtime_base_url: str) -> bool: + """Return whether the sticky runtime currently responds on /health.""" + ... diff --git a/src/api/tests/unit/extraction/application/test_chat_turn_service.py b/src/api/tests/unit/extraction/application/test_chat_turn_service.py index 77f71c573..b579281c1 100644 --- a/src/api/tests/unit/extraction/application/test_chat_turn_service.py +++ b/src/api/tests/unit/extraction/application/test_chat_turn_service.py @@ -89,6 +89,9 @@ async def wait_until_healthy(self, **kwargs): yield "Assistant container is healthy" return + async def is_healthy(self, **kwargs) -> bool: + return True + def _build_chat_turn_service( *, diff --git a/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py index 46fd32977..f16bc3e61 100644 --- a/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py +++ b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py @@ -69,15 +69,47 @@ async def read_for_knowledge_graph(self, *, knowledge_graph_id: str): class _StaticBootstrapBuilder: + async def resolve_job_package_ids(self, **kwargs): + return () + + async def build(self, **kwargs): + return None + + +class _RecordingBootstrapBuilder: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + async def resolve_job_package_ids(self, **kwargs): + return ("pkg-1",) + async def build(self, **kwargs): + self.calls.append(kwargs) return None +class _PreparedIngestionReadinessReader: + async def read_for_knowledge_graph(self, *, knowledge_graph_id: str): + return IngestionReadinessSnapshot(data_source_count=1, prepared_source_count=1) + + class _InstantHealthChecker: async def wait_until_healthy(self, **kwargs): return yield # pragma: no cover + async def is_healthy(self, **kwargs) -> bool: + return True + + +class _UnhealthyHealthChecker: + async def wait_until_healthy(self, **kwargs): + return + yield # pragma: no cover + + async def is_healthy(self, **kwargs) -> bool: + return False + class _FailingStickyRuntimeManager(InMemoryStickySessionRuntimeManager): def get_or_start_runtime(self, **kwargs): @@ -171,21 +203,59 @@ async def test_ensure_runtime_for_chat_reprepares_when_persisted_runtime_is_inac assert any(event.get("type") == "ready" for event in events) assert session.runtime_context["sticky_runtime"]["container_id"] != "dead-container" - assert sticky.try_resolve_active_lease(session_id=session.id) is not None -class _RecordingBootstrapBuilder: - def __init__(self) -> None: - self.calls: list[dict[str, object]] = [] - - async def build(self, **kwargs): - self.calls.append(kwargs) - return None +@pytest.mark.asyncio +async def test_ensure_runtime_for_chat_restarts_when_job_package_materialization_changes() -> None: + repo = _InMemoryAgentSessionRepository() + session_service = ExtractionAgentSessionService(repository=repo) + sticky = InMemoryStickySessionRuntimeManager() + bootstrap = _RecordingBootstrapBuilder() + service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_PreparedIngestionReadinessReader(), + sticky_runtime_manager=sticky, + bootstrap_builder=bootstrap, + health_checker=_InstantHealthChecker(), + runtime_backend="container", + sticky_health_timeout_seconds=5.0, + ) + session = await session_service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + sticky.get_or_start_runtime( + session_id=session.id, + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP.value, + ) + lease = sticky.try_resolve_active_lease(session_id=session.id) + session.runtime_context["workspace_materialization"] = {"job_package_ids": ["stale-pkg"]} + session.runtime_context["sticky_runtime"] = { + "container_id": lease.container_id, + "status": "active", + "runtime_base_url": lease.runtime_base_url, + "phase": "ready", + } + await session_service.save_session(session) + events = [ + event + async for event in service.ensure_runtime_for_chat( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + session=session, + ) + ] -class _PreparedIngestionReadinessReader: - async def read_for_knowledge_graph(self, *, knowledge_graph_id: str): - return IngestionReadinessSnapshot(data_source_count=1, prepared_source_count=1) + assert any(event.get("type") == "ready" for event in events) + assert bootstrap.calls @pytest.mark.asyncio @@ -215,6 +285,8 @@ async def test_ensure_runtime_for_chat_reuses_running_container_without_reprepar knowledge_graph_id="kg-1", mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP.value, ) + session.runtime_context["workspace_materialization"] = {"job_package_ids": ["pkg-1"]} + await session_service.save_session(session) events = [ event @@ -230,11 +302,54 @@ async def test_ensure_runtime_for_chat_reuses_running_container_without_reprepar assert events == [] assert session.runtime_context["sticky_runtime"]["phase"] == "ready" - assert bootstrap.calls == [ - { - "tenant_id": "tenant-1", - "knowledge_graph_id": "kg-1", - "session_id": session.id, - "include_job_packages": True, - } + assert bootstrap.calls == [] + + +@pytest.mark.asyncio +async def test_ensure_runtime_for_chat_restarts_when_persisted_container_is_unhealthy() -> None: + repo = _InMemoryAgentSessionRepository() + session_service = ExtractionAgentSessionService(repository=repo) + sticky = InMemoryStickySessionRuntimeManager() + service = StickySessionRuntimeService( + session_service=session_service, + skill_resolution_service=_StaticSkillResolutionService(), + ingestion_readiness_reader=_StaticIngestionReadinessReader(), + sticky_runtime_manager=sticky, + bootstrap_builder=_StaticBootstrapBuilder(), + health_checker=_UnhealthyHealthChecker(), + runtime_backend="memory", + sticky_health_timeout_seconds=5.0, + ) + session = await session_service.get_or_create_active_session( + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ) + sticky.get_or_start_runtime( + session_id=session.id, + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP.value, + ) + session.runtime_context["sticky_runtime"] = { + "container_id": "dead-container", + "status": "active", + "runtime_base_url": "memory://sticky-runtime", + "phase": "ready", + } + await session_service.save_session(session) + + events = [ + event + async for event in service.ensure_runtime_for_chat( + tenant_id="tenant-1", + user_id="user-1", + knowledge_graph_id="kg-1", + mode=ExtractionSessionMode.SCHEMA_BOOTSTRAP, + ui_mode=GraphManagementUiMode.INITIAL_SCHEMA_DESIGN, + session=session, + ) ] + + assert any(event.get("type") == "ready" for event in events) + assert session.runtime_context["sticky_runtime"]["container_id"] != "dead-container" diff --git a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py index b9fbf1acb..70f96778a 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py +++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py @@ -71,3 +71,23 @@ def test_materializer_does_not_discover_archives_when_package_ids_empty(tmp_path ) assert not any((session_root / "repository-files").iterdir()) + + +def test_materializer_refresh_preserves_session_root_directory(tmp_path: Path) -> None: + package_id = "01JTESTPACK0000000000000002" + _build_package(tmp_path, package_id) + materializer = StickySessionWorkdirMaterializer(job_package_work_dir=tmp_path) + + first_root = materializer.prepare( + session_id="session-3", + knowledge_graph_id="kg-1", + job_package_ids=(package_id,), + ) + second_root = materializer.prepare( + session_id="session-3", + knowledge_graph_id="kg-1", + job_package_ids=(package_id,), + ) + + assert first_root == second_root + assert (second_root / "repository-files" / package_id / "README.md").exists() From d478495533c950c5222b840d80597f402775dcb9 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 20:58:58 -0400 Subject: [PATCH 75/80] refactor(dev-ui): align graph management artifacts panel with k-extract layout Split the combined schema nav/detail card into a sticky left navigator and right detail column to match k-extract's Design Artifacts pattern. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../pages/knowledge-graphs/[kgId]/manage.vue | 195 +++++++++++------- .../knowledge-graph-manage-workspace.test.ts | 15 +- 2 files changed, 125 insertions(+), 85 deletions(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 29fe10dc3..845ff3b9e 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -1,5 +1,5 @@ <script setup lang="ts"> -import { computed, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onMounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' import { ArrowLeft, @@ -374,10 +374,6 @@ const visibleRailItems = computed(() => const schemaRailItems = computed(() => filterSchemaRailItems(visibleRailItems.value)) -const selectedSchemaRailItem = computed(() => - schemaRailItems.value.find((item) => item.id === selectedRailItemId.value) ?? null, -) - const graphManagementModeGate = computed((): GraphManagementModeGateInput => ({ workspaceMode: statusProjection.value?.workspace_mode ?? 'schema_bootstrap', transitionEligible: statusProjection.value?.transition_eligible === true, @@ -915,6 +911,12 @@ function setGraphManagementMode(mode: GraphManagementMode) { function selectSchemaRailItem(itemId: GraphManagementRailItemId) { selectedRailItemId.value = itemId + void nextTick(() => { + document.getElementById('graph-management-artifact-detail')?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + }) } function onSchemaRailKeydown(event: KeyboardEvent, itemId: GraphManagementRailItemId) { @@ -1815,20 +1817,21 @@ watch(selectedOpsDataSourceId, () => { @send-message="sendChatMessage" /> - <div class="graph-management-artifacts grid gap-6 lg:grid-cols-2 lg:items-start"> - <Card id="graph-management-schema-artifacts" class="graph-management-schema-panel scroll-mt-6"> + <div class="graph-management-artifacts grid gap-6 lg:grid-cols-[minmax(0,15.5rem)_minmax(0,1fr)] lg:items-start"> + <Card + id="graph-management-schema-artifacts" + class="graph-management-schema-panel lg:sticky lg:top-4 lg:self-start" + > <CardHeader class="pb-2"> <CardTitle class="text-sm font-semibold">Schema & artifacts</CardTitle> <CardDescription class="text-xs"> Workspace signals for <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span>. - <template v-if="schemaRailItems.length > 1"> - Select an artifact to inspect its detail below. - </template> + Select an artifact to open it in the detail panel to the right. </CardDescription> </CardHeader> - <CardContent class="space-y-4 p-3 pt-0 text-sm"> - <div v-if="schemaRailItems.length > 1" class="space-y-1.5"> + <CardContent class="space-y-1.5 p-3 pt-0"> + <template v-if="schemaRailItems.length > 0"> <button v-for="item in schemaRailItems" :key="item.id" @@ -1843,35 +1846,40 @@ watch(selectedOpsDataSourceId, () => { <span class="font-medium leading-tight">{{ item.label }}</span> <span class="text-xs text-muted-foreground">{{ graphManagementArtifactHint(item) }}</span> </button> - </div> + </template> <p - v-else-if="schemaRailItems.length === 0" + v-else class="rounded-lg border border-dashed p-3 text-xs text-muted-foreground" > No schema artifacts for this mode. </p> + </CardContent> + </Card> - <div class="graph-management-detail space-y-4 border-t pt-4"> - <div> - <p class="text-sm font-semibold"> - {{ selectedSchemaRailItem?.label ?? 'Schema & artifacts' }} - </p> - <p class="text-xs text-muted-foreground"> - Mode: {{ graphManagementModeLabel }} - </p> - </div> - <template v-if="selectedRailItemId === 'schema-readiness'"> - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Bootstrap Progress Checklist + <div id="graph-management-artifact-detail" class="graph-management-detail scroll-mt-6 space-y-6"> + <Card v-if="selectedRailItemId === 'schema-readiness'"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <CheckCircle2 class="size-4" /> + Schema readiness + </CardTitle> + <CardDescription> + Bootstrap checklist, validate, and transition controls for + <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span>. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4 text-sm"> + <div class="space-y-2 rounded-lg border bg-muted/30 p-3"> + <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Bootstrap progress checklist </p> <div class="space-y-2"> <div v-for="item in progressChecklist" :key="item.id" - class="rounded border px-3 py-2" + class="rounded-lg border bg-card px-3 py-2" > - <div class="flex items-center justify-between"> + <div class="flex items-center justify-between gap-2"> <p class="font-medium">{{ item.label }}</p> <Badge :variant="item.passed ? 'default' : 'destructive'"> {{ item.passed ? 'Pass' : 'Fail' }} @@ -1899,16 +1907,24 @@ watch(selectedOpsDataSourceId, () => { Go to Extraction/Mutations </Button> </div> - </template> - - <template v-else-if="selectedRailItemId === 'validation-diagnostics'"> - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Validation Diagnostics - </p> + </CardContent> + </Card> + + <Card v-else-if="selectedRailItemId === 'validation-diagnostics'"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <ShieldAlert class="size-4" /> + Validation diagnostics + </CardTitle> + <CardDescription> + Blocking reasons and prepopulated type gaps before transitioning to extraction. + </CardDescription> + </CardHeader> + <CardContent class="space-y-4 text-sm"> + <div class="space-y-3 rounded-lg border bg-muted/30 p-3"> <div v-if="statusProjection.readiness.prepopulated_types_without_instances.length > 0" - class="rounded border border-amber-400/60 bg-amber-50/60 p-2 text-xs dark:border-amber-800 dark:bg-amber-950/20" + class="rounded-lg border border-amber-400/60 bg-amber-50/60 p-3 text-xs dark:border-amber-800 dark:bg-amber-950/20" > <p class="font-medium text-amber-800 dark:text-amber-300"> Prepopulated types missing instances @@ -1924,7 +1940,7 @@ watch(selectedOpsDataSourceId, () => { </div> <div v-if="statusProjection.readiness.blocking_reasons.length > 0" - class="mt-2 rounded border border-destructive/50 p-3" + class="rounded-lg border border-destructive/50 bg-card p-3" > <p class="mb-1 flex items-center gap-1.5 text-xs font-medium text-destructive"> <ShieldAlert class="size-3.5" /> @@ -1943,21 +1959,29 @@ watch(selectedOpsDataSourceId, () => { No validation diagnostics are currently blocking transition. </p> </div> - <div class="rounded border p-3"> - <p class="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Next Steps + <div class="rounded-lg border bg-muted/30 p-3"> + <p class="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Next steps </p> <ul class="list-disc space-y-1 pl-4 text-xs text-muted-foreground"> <li v-for="step in nextSteps" :key="step">{{ step }}</li> </ul> </div> - </template> - - <template v-else-if="selectedRailItemId === 'extraction-jobs-setup'"> - <p class="text-muted-foreground"> + </CardContent> + </Card> + + <Card v-else-if="selectedRailItemId === 'extraction-jobs-setup'"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <Wrench class="size-4" /> + Extraction jobs setup + </CardTitle> + <CardDescription> Trigger extraction jobs, inspect run history, and view run logs without leaving this workspace. - </p> - <div class="space-y-3 rounded border p-3"> + </CardDescription> + </CardHeader> + <CardContent class="space-y-4 text-sm"> + <div class="space-y-3 rounded-lg border bg-muted/30 p-3"> <p class="text-xs font-medium text-muted-foreground">Data source</p> <div v-if="graphManagementDataSourcesLoading" @@ -2007,7 +2031,7 @@ watch(selectedOpsDataSourceId, () => { </Button> </div> <div class="grid gap-3 xl:grid-cols-[300px_1fr]"> - <div class="rounded border"> + <div class="rounded-lg border bg-card"> <div class="border-b px-3 py-2 text-xs font-medium text-muted-foreground">Sync runs</div> <div v-if="inlineSyncRunsLoading" @@ -2026,7 +2050,7 @@ watch(selectedOpsDataSourceId, () => { <button v-for="run in inlineSyncRuns" :key="run.id" - class="w-full rounded border px-2 py-1.5 text-left text-xs transition-colors" + class="w-full rounded-lg border px-2 py-1.5 text-left text-xs transition-colors" :class="selectedInlineRunId === run.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'" @click="loadInlineRunLogs(run.id)" > @@ -2040,7 +2064,7 @@ watch(selectedOpsDataSourceId, () => { </button> </div> </div> - <div class="rounded border p-3"> + <div class="rounded-lg border bg-muted/30 p-3"> <p class="mb-2 text-xs font-medium text-muted-foreground"> Run logs <span v-if="selectedOpsDataSource" class="font-normal text-muted-foreground/80"> @@ -2059,24 +2083,32 @@ watch(selectedOpsDataSourceId, () => { </div> <pre v-else - class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 text-[11px]" + class="max-h-72 overflow-auto rounded-lg border bg-background p-2 text-[11px]" >{{ inlineRunLogs.join('\n') }}</pre> </div> </div> - </template> - - <template v-else-if="selectedRailItemId === 'mutation-authoring'"> - <p class="text-muted-foreground"> + </CardContent> + </Card> + + <Card v-else-if="selectedRailItemId === 'mutation-authoring'"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <PencilRuler class="size-4" /> + Mutation authoring + </CardTitle> + <CardDescription> Author and apply one-off JSONL mutations directly in this workspace. - </p> - <div class="space-y-3 rounded border p-3"> + </CardDescription> + </CardHeader> + <CardContent class="space-y-3 text-sm"> + <div class="space-y-3 rounded-lg border bg-muted/30 p-3"> <p class="text-xs font-medium text-muted-foreground">Mutation payload (JSONL)</p> <textarea v-model="inlineMutationJsonl" - class="min-h-44 w-full rounded border bg-background px-3 py-2 font-mono text-xs" + class="min-h-44 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" placeholder='{"op":"CREATE","type":"node","label":"repo","id":"repo:example","set_properties":{"name":"example"}}' /> - <div class="flex items-center gap-2"> + <div class="flex flex-wrap items-center gap-2"> <Button size="sm" :disabled="inlineMutationApplying" @click="applyInlineMutations"> <Loader2 v-if="inlineMutationApplying" class="mr-1.5 size-3.5 animate-spin" /> Apply Mutations @@ -2089,39 +2121,43 @@ watch(selectedOpsDataSourceId, () => { {{ inlineMutationApplyError }} </p> </div> - </template> - - <template v-else> - <p class="text-xs text-muted-foreground"> - Select a schema artifact to inspect mode-specific workspace content. - </p> - </template> - </div> - </CardContent> - </Card> + </CardContent> + </Card> - <Card id="graph-management-session-pointers" class="graph-management-session-pointers scroll-mt-6 lg:sticky lg:top-4 lg:self-start"> - <CardHeader class="pb-3"> - <CardTitle class="text-base">Session pointers</CardTitle> + <Card v-else> + <CardHeader> + <CardTitle class="text-base">Schema & artifacts</CardTitle> + <CardDescription> + Select a schema artifact from the list to inspect mode-specific workspace content. + </CardDescription> + </CardHeader> + </Card> + + <Card id="graph-management-session-pointers" class="graph-management-session-pointers"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <ScrollText class="size-4" /> + Session pointers + </CardTitle> <CardDescription> Active bootstrap and extraction sessions, plus archived history for this knowledge graph. </CardDescription> </CardHeader> <CardContent class="space-y-4 text-sm"> <div class="grid gap-2 md:grid-cols-3 text-xs"> - <div class="rounded-lg border px-3 py-2"> + <div class="rounded-lg border bg-muted/30 px-3 py-2"> <p class="text-muted-foreground">Active schema bootstrap session</p> <p class="mt-1 break-all font-mono"> {{ statusProjection.session_pointers.active_schema_bootstrap_session_id ?? 'None' }} </p> </div> - <div class="rounded-lg border px-3 py-2"> + <div class="rounded-lg border bg-muted/30 px-3 py-2"> <p class="text-muted-foreground">Active extraction operations session</p> <p class="mt-1 break-all font-mono"> {{ statusProjection.session_pointers.active_extraction_operations_session_id ?? 'None' }} </p> </div> - <div class="rounded-lg border px-3 py-2"> + <div class="rounded-lg border bg-muted/30 px-3 py-2"> <p class="text-muted-foreground">Most recent completed session</p> <p class="mt-1 break-all font-mono"> {{ statusProjection.session_pointers.most_recent_completed_session_id ?? 'None' }} @@ -2130,8 +2166,8 @@ watch(selectedOpsDataSourceId, () => { </div> <div class="space-y-3 border-t pt-3"> <div class="flex items-center justify-between"> - <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground"> - Session History + <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Session history </p> <Button size="sm" @@ -2160,7 +2196,7 @@ watch(selectedOpsDataSourceId, () => { <div v-for="entry in sessionHistory" :key="entry.id" - class="rounded-lg border px-3 py-2 text-xs" + class="rounded-lg border bg-card px-3 py-2 text-xs" > <div class="flex flex-wrap items-center justify-between gap-2"> <p class="font-mono break-all">{{ entry.id }}</p> @@ -2198,7 +2234,8 @@ watch(selectedOpsDataSourceId, () => { </div> </div> </CardContent> - </Card> + </Card> + </div> </div> </section> </template> diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index a0cdc8aac..7cfde7900 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -81,7 +81,7 @@ describe('Knowledge Graph Manage Workspace - graph management controls', () => { expect(manageWorkspaceVue).toContain('/sessions/${sharedSessionMode.value}/history') expect(manageWorkspaceVue).toContain('sessionHistory') expect(manageWorkspaceVue).toContain('run_metrics') - expect(manageWorkspaceVue).toContain('Session History') + expect(manageWorkspaceVue).toContain('Session history') }) }) @@ -156,7 +156,7 @@ describe('KG-MANAGE-014 - no-preview fallback state', () => { describe('Knowledge Graph Manage Workspace - bootstrap readiness guidance', () => { it('renders a bootstrap progress checklist section with explicit checks', () => { - expect(manageWorkspaceVue).toContain('Bootstrap Progress Checklist') + expect(manageWorkspaceVue).toContain('Bootstrap progress checklist') expect(manageWorkspaceVue).toContain('progressChecklist') expect(manageWorkspaceVue).toContain('Minimum entity types') expect(manageWorkspaceVue).toContain('Minimum relationship types') @@ -164,13 +164,13 @@ describe('Knowledge Graph Manage Workspace - bootstrap readiness guidance', () = }) it('renders diagnostics panel with prepopulated type failures and blocking reasons', () => { - expect(manageWorkspaceVue).toContain('Validation Diagnostics') + expect(manageWorkspaceVue).toContain('Validation diagnostics') expect(manageWorkspaceVue).toContain('prepopulated_types_without_instances') expect(manageWorkspaceVue).toContain('blocking_reasons') }) it('renders explicit next steps guidance for transition readiness', () => { - expect(manageWorkspaceVue).toContain('Next Steps') + expect(manageWorkspaceVue).toContain('Next steps') expect(manageWorkspaceVue).toContain('Run Validate to refresh readiness signals') expect(manageWorkspaceVue).toContain('Transition is enabled') }) @@ -423,12 +423,14 @@ describe('KG-MANAGE-007 - graph management modes', () => { }) describe('KG-MANAGE-008 - hybrid lower panel shared rail', () => { - it('renders side-by-side schema artifacts and session pointers panels', () => { + it('renders artifact navigator and detail panel in k-extract-style layout', () => { expect(manageWorkspaceVue).toContain('graph-management-artifacts') expect(manageWorkspaceVue).toContain('Schema & artifacts') + expect(manageWorkspaceVue).toContain('graph-management-artifact-detail') expect(manageWorkspaceVue).toContain('graph-management-session-pointers') expect(manageWorkspaceVue).toContain('graphManagementArtifactRowClass') expect(manageWorkspaceVue).toContain('schemaRailItems') + expect(manageWorkspaceVue).toContain('lg:grid-cols-[minmax(0,15.5rem)_minmax(0,1fr)]') }) it('builds rail items with status and last-updated metadata', () => { @@ -450,6 +452,7 @@ describe('KG-MANAGE-008 - hybrid lower panel shared rail', () => { describe('KG-MANAGE-009 - hybrid lower panel mode-specific detail', () => { it('renders mode-specific detail panel content regions', () => { + expect(manageWorkspaceVue).toContain('graph-management-artifact-detail') expect(manageWorkspaceVue).toContain('graph-management-detail') expect(manageWorkspaceVue).toContain('selectedRailItemId') expect(manageWorkspaceVue).toContain("selectedRailItemId === 'schema-readiness'") @@ -479,7 +482,7 @@ describe('KG-MANAGE-009 - hybrid lower panel mode-specific detail', () => { describe('KG-MANAGE-010 - schema design parity behavior', () => { it('exposes schema readiness and validation detail in initial schema design mode', () => { expect(manageWorkspaceVue).toContain('progressChecklist') - expect(manageWorkspaceVue).toContain('Bootstrap Progress Checklist') + expect(manageWorkspaceVue).toContain('Bootstrap progress checklist') expect(manageWorkspaceVue).toContain('blocking_reasons') expect(manageWorkspaceVue).toContain('prepopulated_types_without_instances') }) From 2072434f4c20377f4fb94eff84a4b3366bbddfc6 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 21:08:32 -0400 Subject: [PATCH 76/80] feat(agent-runtime): stream intermediate thinking lines during SDK turns Surface tool use, reasoning, task progress, and compose previews as NDJSON thinking events so the Graph Management Assistant panel updates while Vertex work is in flight. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../kartograph_agent_runtime/executor.py | 27 ++- .../thinking_stream.py | 175 ++++++++++++++++++ .../tests/test_thinking_stream.py | 104 +++++++++++ .../extraction/SharedConversationPanel.vue | 8 +- .../pages/knowledge-graphs/[kgId]/manage.vue | 10 +- .../knowledge-graph-manage-workspace.test.ts | 2 +- 6 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 src/agent-runtime/kartograph_agent_runtime/thinking_stream.py create mode 100644 src/agent-runtime/tests/test_thinking_stream.py diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py index 5afea4bd7..e00879fba 100644 --- a/src/agent-runtime/kartograph_agent_runtime/executor.py +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -8,6 +8,10 @@ from typing import Any from kartograph_agent_runtime.settings import AgentRuntimeSettings +from kartograph_agent_runtime.thinking_stream import ( + initial_sdk_thinking_lines, + thinking_events_from_sdk_message, +) from kartograph_agent_runtime.tools import RuntimeTooling from kartograph_agent_runtime.vertex import build_claude_agent_env @@ -196,14 +200,8 @@ async def _stream_with_claude_sdk( if history_lines: prompt = "Recent conversation:\n" + "\n".join(history_lines) + f"\n\nUser: {message}" - yield { - "type": "thinking", - "recent": [ - f"Claude Agent SDK query started ({auth_mode})…", - f"Mode overlay: {ui_mode}", - "Tools: graph read enclave, mutation emitter", - ], - } + recent = initial_sdk_thinking_lines(auth_mode=auth_mode, ui_mode=ui_mode) + yield {"type": "thinking", "recent": list(recent)} sdk_env = _build_sdk_env(settings) workspace_dir = settings.workspace_dir.strip() or "/workspace" @@ -218,12 +216,25 @@ async def _stream_with_claude_sdk( ) reply: str | None = None + reply_parts: list[str] = [] + last_compose_at = 0 try: async with asyncio.timeout(turn_timeout_seconds): async for sdk_message in query(prompt=prompt, options=options): + thinking_events, last_compose_at = thinking_events_from_sdk_message( + sdk_message, + recent=recent, + reply_parts=reply_parts, + last_compose_at=last_compose_at, + ) + for event in thinking_events: + yield event + extracted = _extract_sdk_reply(sdk_message) if extracted: reply = extracted + elif reply_parts: + reply = "".join(reply_parts).strip() or None except TimeoutError: yield { "type": "done", diff --git a/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py new file mode 100644 index 000000000..f474a0858 --- /dev/null +++ b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py @@ -0,0 +1,175 @@ +"""Rolling thinking-line panel updates for NDJSON chat streams.""" + +from __future__ import annotations + +from typing import Any + +_MAX_THINKING_LINES = 8 + + +def normalize_activity_line(text: str) -> str: + line = " ".join(text.split()) + if len(line) > 120: + return line[:117] + "…" + return line + + +def push_thinking(recent: list[str], line: str) -> dict[str, Any] | None: + normalized = normalize_activity_line(line) + if not normalized: + return None + if recent and recent[-1] == normalized: + return None + recent.append(normalized) + if len(recent) > _MAX_THINKING_LINES: + recent[:] = recent[-_MAX_THINKING_LINES:] + return {"type": "thinking", "recent": list(recent)} + + +def update_composing_line(recent: list[str], preview_tail: str) -> dict[str, Any] | None: + preview_tail = normalize_activity_line(preview_tail.replace("\n", " ")) + line = normalize_activity_line( + f"Composing reply · {preview_tail}" if preview_tail else "Composing reply…", + ) + prefix = "Composing reply" + if recent and str(recent[-1]).startswith(prefix): + recent[-1] = line + return {"type": "thinking", "recent": list(recent)} + return push_thinking(recent, line) + + +def _tool_use_line(name: str, tool_input: dict[str, Any]) -> str: + if name == "Read": + path = tool_input.get("file_path") or tool_input.get("path") or "" + return f"Reading {path}" if path else "Reading file…" + if name in {"Write", "Edit"}: + path = tool_input.get("file_path") or tool_input.get("path") or "" + verb = "Writing" if name == "Write" else "Editing" + return f"{verb} {path}" if path else f"{verb} file…" + if name == "Grep": + pattern = tool_input.get("pattern") or "" + return f"Searching for {pattern}" if pattern else "Searching repository…" + if name == "Glob": + pattern = tool_input.get("pattern") or "" + return f"Listing files {pattern}" if pattern else "Listing files…" + if name == "Bash": + command = tool_input.get("command") or "" + return f"Running {command}" if command else "Running shell command…" + return f"Running {name}…" + + +def _stream_event_line(event: dict[str, Any]) -> str | None: + event_type = event.get("type") + if event_type == "content_block_start": + block = event.get("content_block") or {} + block_type = block.get("type") + if block_type == "tool_use": + name = block.get("name") or "tool" + return f"Running {name}…" + if block_type == "thinking": + return "Reasoning…" + if event_type == "content_block_delta": + delta = event.get("delta") or {} + if delta.get("type") == "thinking_delta": + thinking = str(delta.get("thinking") or "").strip() + if thinking: + return f"Reasoning · {normalize_activity_line(thinking)}" + if delta.get("type") == "text_delta": + text = str(delta.get("text") or "").strip() + if text: + return None # handled via composing line from accumulated text + return None + + +def thinking_events_from_sdk_message( + sdk_message: Any, + *, + recent: list[str], + reply_parts: list[str], + last_compose_at: int, + compose_step: int = 120, +) -> tuple[list[dict[str, Any]], int]: + """Return thinking NDJSON events and updated compose offset for one SDK message.""" + events: list[dict[str, Any]] = [] + + content = getattr(sdk_message, "content", None) + if isinstance(content, list): + for block in content: + block_type = type(block).__name__ + if block_type == "ThinkingBlock" or hasattr(block, "thinking"): + thinking = normalize_activity_line(getattr(block, "thinking", "") or "") + if thinking: + event = push_thinking(recent, f"Reasoning · {thinking}") + if event: + events.append(event) + elif block_type == "ToolUseBlock" or hasattr(block, "name"): + name = str(getattr(block, "name", "") or "tool") + tool_input = getattr(block, "input", None) or {} + if not isinstance(tool_input, dict): + tool_input = {} + event = push_thinking(recent, _tool_use_line(name, tool_input)) + if event: + events.append(event) + elif block_type == "TextBlock" or hasattr(block, "text"): + text = str(getattr(block, "text", "") or "") + if text.strip(): + reply_parts.append(text) + blob = "".join(reply_parts) + plain = text.replace("\n", "").strip() + if plain and len(blob) - last_compose_at >= compose_step: + tail = blob[-88:].replace("\n", " ").strip() + event = update_composing_line(recent, tail) + if event: + events.append(event) + last_compose_at = len(blob) + return events, last_compose_at + + task_id = getattr(sdk_message, "task_id", None) + description = str(getattr(sdk_message, "description", "") or "").strip() + if task_id and description: + last_tool = str(getattr(sdk_message, "last_tool_name", "") or "").strip() + usage = getattr(sdk_message, "usage", None) + prefix = "Task started ·" if usage is None and not last_tool else "" + line = f"{prefix}{description}".strip() + event = push_thinking(recent, line) + if event: + events.append(event) + if last_tool: + event = push_thinking(recent, f"Running {last_tool}…") + if event: + events.append(event) + return events, last_compose_at + + payload = getattr(sdk_message, "event", None) + if isinstance(payload, dict): + line = _stream_event_line(payload) + if line: + event = push_thinking(recent, line) + if event: + events.append(event) + return events, last_compose_at + + subtype = str(getattr(sdk_message, "subtype", "") or "").strip() + data = getattr(sdk_message, "data", None) or {} + if subtype == "task_progress" and isinstance(data, dict): + progress_description = str(data.get("description") or "").strip() + last_tool = str(data.get("last_tool_name") or "").strip() + if progress_description: + event = push_thinking(recent, progress_description) + if event: + events.append(event) + if last_tool: + event = push_thinking(recent, f"Running {last_tool}…") + if event: + events.append(event) + + return events, last_compose_at + + +def initial_sdk_thinking_lines(*, auth_mode: str, ui_mode: str) -> list[str]: + return [ + f"Claude Agent SDK query started ({auth_mode})…", + f"Mode overlay: {ui_mode}", + "Tools: graph read enclave, mutation emitter", + "Connected — working on your message…", + ] diff --git a/src/agent-runtime/tests/test_thinking_stream.py b/src/agent-runtime/tests/test_thinking_stream.py new file mode 100644 index 000000000..b399c70da --- /dev/null +++ b/src/agent-runtime/tests/test_thinking_stream.py @@ -0,0 +1,104 @@ +"""Unit tests for rolling thinking-line stream helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from kartograph_agent_runtime.thinking_stream import ( + initial_sdk_thinking_lines, + push_thinking, + thinking_events_from_sdk_message, +) + + +@dataclass +class FakeToolUseBlock: + name: str + input: dict + + +@dataclass +class FakeThinkingBlock: + thinking: str + + +@dataclass +class FakeTextBlock: + text: str + + +@dataclass +class FakeAssistantMessage: + content: list + + +@dataclass +class FakeTaskProgressMessage: + task_id: str + description: str + last_tool_name: str | None = None + usage: dict | None = None + + +def test_initial_sdk_thinking_lines_include_connected_message() -> None: + lines = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + + assert any("Claude Agent SDK query started" in line for line in lines) + assert any("Connected" in line for line in lines) + + +def test_push_thinking_deduplicates_and_caps_recent_lines() -> None: + recent: list[str] = [] + first = push_thinking(recent, "Reading schema.yaml") + second = push_thinking(recent, "Reading schema.yaml") + third = push_thinking(recent, "Running Grep…") + + assert first is not None + assert second is None + assert third is not None + assert recent[-1] == "Running Grep…" + + +def test_thinking_events_from_assistant_message_tool_and_reasoning_blocks() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + message = FakeAssistantMessage( + content=[ + FakeThinkingBlock(thinking="Need to inspect entity ontology first."), + FakeToolUseBlock(name="Read", input={"file_path": "/workspace/entity_ontology.json"}), + FakeTextBlock(text="I reviewed the ontology and found three entity types."), + ], + ) + + events, _ = thinking_events_from_sdk_message( + message, + recent=recent, + reply_parts=[], + last_compose_at=0, + compose_step=10, + ) + + assert events + assert any("Reasoning" in line for line in events[-1]["recent"]) + assert any("Reading /workspace/entity_ontology.json" in line for line in events[-1]["recent"]) + + +def test_thinking_events_from_task_progress_message() -> None: + recent = initial_sdk_thinking_lines(auth_mode="Vertex AI", ui_mode="initial-schema-design") + message = FakeTaskProgressMessage( + task_id="task-1", + description="Inspecting repository files", + last_tool_name="Grep", + usage={"total_tokens": 1, "tool_uses": 1, "duration_ms": 1}, + ) + + events, _ = thinking_events_from_sdk_message( + message, + recent=recent, + reply_parts=[], + last_compose_at=0, + ) + + assert events + joined = "\n".join(events[-1]["recent"]) + assert "Inspecting repository files" in joined + assert "Running Grep" in joined diff --git a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue index dbd43ee16..1bd788399 100644 --- a/src/dev-ui/app/components/extraction/SharedConversationPanel.vue +++ b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue @@ -91,10 +91,10 @@ const runtimeActivityTitle = computed(() => : 'Thinking...', ) -const thinkingDisplaySlots = computed(() => { +const thinkingDisplayLines = computed(() => { const src = props.activityLines.filter(Boolean) if (src.length === 0) return [''] - return src.slice(-3) + return src }) function isUserRole(role: string | undefined): boolean { @@ -323,9 +323,9 @@ onMounted(() => { <Loader2 class="size-4 shrink-0 animate-spin text-primary" aria-hidden="true" /> <span class="font-medium tracking-tight">{{ runtimeActivityTitle }}</span> </div> - <ol class="m-0 list-none space-y-2 border-l-2 border-primary/25 pl-3"> + <ol class="m-0 max-h-48 list-none space-y-2 overflow-y-auto border-l-2 border-primary/25 pl-3"> <li - v-for="(line, lineIdx) in thinkingDisplaySlots" + v-for="(line, lineIdx) in thinkingDisplayLines" :key="`${lineIdx}-${line || 'empty'}`" class="flex gap-2 text-xs leading-snug" > diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 845ff3b9e..49e66a397 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -972,7 +972,10 @@ async function warmupAssistantRuntime() { })) { if (generation !== runtimeWarmupGeneration) return if (event.type === 'thinking' && Array.isArray(event.recent)) { - sessionActivityLines.value = event.recent.filter(Boolean) + const recent = event.recent.filter(Boolean) + sessionActivityLines.value = recent.length > 0 + ? recent + : sessionActivityLines.value } if (event.type === 'wait' && event.message) { sessionActivityLines.value = [event.message] @@ -1047,7 +1050,10 @@ async function sendChatMessage(message: string) { message: trimmed, })) { if (event.type === 'thinking' && Array.isArray(event.recent)) { - sessionActivityLines.value = event.recent.filter(Boolean) + const recent = event.recent.filter(Boolean) + sessionActivityLines.value = recent.length > 0 + ? recent + : sessionActivityLines.value } if (event.type === 'wait') { sessionActivityLines.value = event.message diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 7cfde7900..6bea76b83 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -367,7 +367,7 @@ describe('Shared conversation panel - extraction UX contract', () => { }) it('renders bubble chat, thinking state, and auto-scroll', () => { - expect(sharedConversationPanelVue).toContain('thinkingDisplaySlots') + expect(sharedConversationPanelVue).toContain('thinkingDisplayLines') expect(sharedConversationPanelVue).toContain('chatScrollRef') expect(sharedConversationPanelVue).toContain('renderAssistantHtml') expect(sharedConversationPanelVue).toContain('scrollToBottom') From cfad11f6acd320a9244e8b184359113304f72ea3 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 22:31:39 -0400 Subject: [PATCH 77/80] fix(extraction): materialize non-empty prepared sources for agent workspaces Ensure ingest-only prepares full-branch JobPackages and only materialize packages that contain repository content so Graph Management sessions can reliably read repo files. Add workspace source indexing plus prompt/thinking updates so the agent reports accurate available files and tools. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../kartograph_agent_runtime/executor.py | 44 ++++++- .../thinking_stream.py | 2 +- src/agent-runtime/tests/test_executor.py | 34 ++++++ src/api/extraction/dependencies.py | 5 +- .../prepared_job_package_reader.py | 66 +++++++++-- .../sticky_session_workdir_materializer.py | 50 +++++++- .../application/services/ingestion_service.py | 14 ++- .../ingestion/infrastructure/event_handler.py | 19 ++++ .../job_package_archive_reader.py | 35 ++++-- .../presentation/data_sources/routes.py | 15 ++- .../job_package/archive_availability.py | 11 ++ .../test_prepared_job_package_reader.py | 107 ++++++++++++++++++ ...est_sticky_session_workdir_materializer.py | 51 +++++++++ .../application/test_ingestion_service.py | 26 +++++ .../test_ingestion_event_handler.py | 4 + .../presentation/test_data_sources_routes.py | 18 ++- .../job_package/test_archive_availability.py | 10 ++ 17 files changed, 475 insertions(+), 36 deletions(-) create mode 100644 src/api/tests/unit/extraction/infrastructure/test_prepared_job_package_reader.py diff --git a/src/agent-runtime/kartograph_agent_runtime/executor.py b/src/agent-runtime/kartograph_agent_runtime/executor.py index e00879fba..e96be5f1a 100644 --- a/src/agent-runtime/kartograph_agent_runtime/executor.py +++ b/src/agent-runtime/kartograph_agent_runtime/executor.py @@ -37,15 +37,54 @@ def _build_system_prompt( def _build_workspace_prompt_appendix(settings: AgentRuntimeSettings) -> str: + import json from pathlib import Path root = Path(settings.workspace_dir) + index_path = root / "sources-index.json" + if index_path.is_file(): + try: + index = json.loads(index_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + index = None + sources = index.get("sources") if isinstance(index, dict) else None + if isinstance(sources, list) and sources: + lines = [ + "## Session workspace", + f"Workspace mount: `{settings.workspace_dir}`", + ( + "Prepared repository files live under " + "`repository-files/<job_package_id>/` relative to the workspace mount. " + "Use Read, Grep, and Glob tools against those paths." + ), + ] + for source in sources[:12]: + if not isinstance(source, dict): + continue + package_id = str(source.get("job_package_id") or "?") + entry_count = source.get("entry_count", 0) + repository_root = str( + source.get("repository_root") or f"repository-files/{package_id}" + ) + data_source_id = str(source.get("data_source_id") or "?") + lines.append( + f"- `{repository_root}`: {entry_count} file(s) " + f"(data source `{data_source_id}`)" + ) + sample_paths = source.get("sample_paths") + if isinstance(sample_paths, list): + for path in sample_paths[:6]: + if path: + lines.append(f" - `{path}`") + return "\n".join(lines) + repo_root = root / "repository-files" if not repo_root.is_dir(): return ( f"## Session workspace\n" f"Workspace mount: `{settings.workspace_dir}`\n" - "No prepared JobPackage repository files are materialized yet." + "No prepared JobPackage repository files are materialized yet. " + "Prepare data sources under Graph Management → Data sources." ) package_dirs = sorted(path for path in repo_root.iterdir() if path.is_dir()) @@ -54,8 +93,7 @@ def _build_workspace_prompt_appendix(settings: AgentRuntimeSettings) -> str: f"## Session workspace\n" f"Workspace mount: `{settings.workspace_dir}`\n" "Prepared data sources exist, but repository files have not been extracted yet. " - "If the user asks about repository content, explain that ingestion context may " - "need to be re-prepared under Data sources." + "Re-prepare data sources under Graph Management → Data sources." ) lines = [ diff --git a/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py index f474a0858..b215b859d 100644 --- a/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py +++ b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py @@ -170,6 +170,6 @@ def initial_sdk_thinking_lines(*, auth_mode: str, ui_mode: str) -> list[str]: return [ f"Claude Agent SDK query started ({auth_mode})…", f"Mode overlay: {ui_mode}", - "Tools: graph read enclave, mutation emitter", + "Tools: Read, Grep, Glob on workspace repository-files", "Connected — working on your message…", ] diff --git a/src/agent-runtime/tests/test_executor.py b/src/agent-runtime/tests/test_executor.py index 865513b8c..1af437dd2 100644 --- a/src/agent-runtime/tests/test_executor.py +++ b/src/agent-runtime/tests/test_executor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -14,6 +15,39 @@ from kartograph_agent_runtime.settings import AgentRuntimeSettings +def test_build_workspace_prompt_appendix_prefers_sources_index(tmp_path: Path) -> None: + package_id = "pkg-1" + package_root = tmp_path / "repository-files" / package_id / "pkg" / "api" + package_root.mkdir(parents=True) + (package_root / "adapter_status_types_test.go").write_text("package api\n", encoding="utf-8") + (tmp_path / "sources-index.json").write_text( + json.dumps( + { + "version": 1, + "knowledge_graph_id": "kg-1", + "sources": [ + { + "job_package_id": package_id, + "data_source_id": "ds-hyperfleet-api", + "entry_count": 142, + "repository_root": f"repository-files/{package_id}", + "sample_paths": ["pkg/api/adapter_status_types_test.go"], + } + ], + } + ), + encoding="utf-8", + ) + + appendix = _build_workspace_prompt_appendix( + AgentRuntimeSettings(KARTOGRAPH_WORKSPACE_DIR=str(tmp_path)) + ) + + assert "ds-hyperfleet-api" in appendix + assert "142 file(s)" in appendix + assert "pkg/api/adapter_status_types_test.go" in appendix + + def test_build_workspace_prompt_appendix_lists_materialized_repository_files( tmp_path: Path, ) -> None: diff --git a/src/api/extraction/dependencies.py b/src/api/extraction/dependencies.py index 343c8fcd8..fbd2387fe 100644 --- a/src/api/extraction/dependencies.py +++ b/src/api/extraction/dependencies.py @@ -104,7 +104,10 @@ def get_extraction_chat_turn_service( ) bootstrap_builder = StickySessionBootstrapBuilder( credential_issuer=get_workload_credential_issuer(), - prepared_job_package_reader=SqlPreparedJobPackageReader(session=session), + prepared_job_package_reader=SqlPreparedJobPackageReader( + session=session, + job_package_work_dir=Path(runtime_settings.job_package_work_dir), + ), workdir_materializer=StickySessionWorkdirMaterializer( job_package_work_dir=Path(runtime_settings.job_package_work_dir), ), diff --git a/src/api/extraction/infrastructure/prepared_job_package_reader.py b/src/api/extraction/infrastructure/prepared_job_package_reader.py index b99684894..1265dcf94 100644 --- a/src/api/extraction/infrastructure/prepared_job_package_reader.py +++ b/src/api/extraction/infrastructure/prepared_job_package_reader.py @@ -2,15 +2,26 @@ from __future__ import annotations +from pathlib import Path + from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from shared_kernel.job_package.reader import JobPackageReader +from shared_kernel.job_package.value_objects import JobPackageId + class SqlPreparedJobPackageReader: - """Reads latest prepared JobPackage ids from outbox events for one knowledge graph.""" + """Reads latest materializable JobPackage ids from outbox events for one KG.""" - def __init__(self, *, session: AsyncSession) -> None: + def __init__( + self, + *, + session: AsyncSession, + job_package_work_dir: Path, + ) -> None: self._session = session + self._job_package_work_dir = job_package_work_dir async def list_latest_for_knowledge_graph( self, *, knowledge_graph_id: str @@ -18,8 +29,10 @@ async def list_latest_for_knowledge_graph( result = await self._session.execute( text( """ - SELECT DISTINCT ON (payload->>'data_source_id') - payload->>'job_package_id' AS job_package_id + SELECT + payload->>'data_source_id' AS data_source_id, + payload->>'job_package_id' AS job_package_id, + occurred_at FROM outbox WHERE event_type IN ('IngestionPrepared', 'JobPackageProduced') AND payload->>'knowledge_graph_id' = :knowledge_graph_id @@ -29,9 +42,42 @@ async def list_latest_for_knowledge_graph( ), {"knowledge_graph_id": knowledge_graph_id}, ) - package_ids = tuple( - str(row.job_package_id) - for row in result - if row.job_package_id is not None and str(row.job_package_id).strip() - ) - return package_ids + rows = result.fetchall() + + by_source: dict[str, list] = {} + for row in rows: + data_source_id = str(row.data_source_id or "").strip() + if not data_source_id: + continue + by_source.setdefault(data_source_id, []).append(row) + + selected: list[str] = [] + for data_source_id in sorted(by_source): + package_id = self._first_materializable_package_id( + rows=by_source[data_source_id], + ) + if package_id is not None: + selected.append(package_id) + + return tuple(selected) + + def _first_materializable_package_id(self, *, rows) -> str | None: + for row in rows: + package_id = str(row.job_package_id or "").strip() + if not package_id: + continue + if self._package_has_repository_content(package_id): + return package_id + return None + + def _package_has_repository_content(self, package_id: str) -> bool: + archive_path = self._job_package_work_dir / JobPackageId( + value=package_id + ).archive_name() + if not archive_path.is_file(): + return False + try: + manifest = JobPackageReader(archive_path).read_manifest() + except (OSError, ValueError): + return False + return manifest.entry_count > 0 diff --git a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py index 74a0ef8c9..bac5f08f3 100644 --- a/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py +++ b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import shutil import zipfile @@ -10,6 +11,8 @@ from shared_kernel.job_package.reader import JobPackageReader from shared_kernel.job_package.value_objects import JobPackageId +_WORKSPACE_INDEX_FILENAME = "sources-index.json" + def _replace_directory(path: Path) -> None: """Replace a directory tree without removing its parent mount point.""" @@ -44,10 +47,16 @@ def prepare( if job_package_ids is None else job_package_ids ) + index_sources: list[dict[str, object]] = [] for package_id in discovered: archive_path = self._job_package_work_dir / JobPackageId(value=package_id).archive_name() if not archive_path.exists(): continue + reader = JobPackageReader(archive_path) + manifest = reader.read_manifest() + if manifest.entry_count <= 0: + continue + package_dir = ingestion_context_dir / package_id package_dir.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(archive_path) as archive: @@ -55,7 +64,7 @@ def prepare( validate_zip_entry_name(entry_name) archive.extract(entry_name, path=package_dir) - reader = JobPackageReader(archive_path) + sample_paths: list[str] = [] for change in reader.iter_changeset(): if change.content_ref is None or not change.path: continue @@ -63,9 +72,27 @@ def prepare( output_path = repository_files_dir / package_id / change.path output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(reader.read_content(change.content_ref)) + if len(sample_paths) < 8: + sample_paths.append(change.path) + + index_sources.append( + { + "job_package_id": package_id, + "data_source_id": manifest.data_source_id, + "entry_count": manifest.entry_count, + "sync_mode": str(manifest.sync_mode), + "repository_root": f"repository-files/{package_id}", + "sample_paths": sample_paths, + } + ) marker = session_root / "knowledge-graph-id" marker.write_text(knowledge_graph_id, encoding="utf-8") + self._write_workspace_index( + session_root=session_root, + knowledge_graph_id=knowledge_graph_id, + sources=index_sources, + ) return session_root def _discover_job_package_ids(self) -> tuple[str, ...]: @@ -75,3 +102,24 @@ def _discover_job_package_ids(self) -> tuple[str, ...]: if stem: package_ids.append(stem) return tuple(package_ids) + + def _write_workspace_index( + self, + *, + session_root: Path, + knowledge_graph_id: str, + sources: list[dict[str, object]], + ) -> None: + index_path = session_root / _WORKSPACE_INDEX_FILENAME + index_path.write_text( + json.dumps( + { + "version": 1, + "knowledge_graph_id": knowledge_graph_id, + "sources": sources, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) diff --git a/src/api/ingestion/application/services/ingestion_service.py b/src/api/ingestion/application/services/ingestion_service.py index 3abfd9472..a9dd1892f 100644 --- a/src/api/ingestion/application/services/ingestion_service.py +++ b/src/api/ingestion/application/services/ingestion_service.py @@ -61,6 +61,7 @@ async def run( tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, + pipeline_mode: str = "full", ) -> IngestionRunResult: """Run the ingestion pipeline for a data source sync. @@ -103,25 +104,30 @@ async def run( ) checkpoint = None - if baseline_commit: + sync_mode = SyncMode.INCREMENTAL + if pipeline_mode == "ingest_only": + # Graph-management prepare must snapshot the full branch so the sticky + # session workspace contains every repository file, not just deltas. + sync_mode = SyncMode.FULL_REFRESH + elif baseline_commit: checkpoint = AdapterCheckpoint( schema_version="1.0.0", data={"commit_sha": baseline_commit}, ) - + # Extract raw items from the adapter using the new ExtractionResult API result = await adapter.extract( connection_config=connection_config, credentials=resolved_credentials, checkpoint=checkpoint, - sync_mode=SyncMode.INCREMENTAL, + sync_mode=sync_mode, ) # Build the JobPackage builder = JobPackageBuilder( data_source_id=data_source_id, knowledge_graph_id=knowledge_graph_id, - sync_mode=SyncMode.INCREMENTAL, + sync_mode=sync_mode, ) # Register content blobs (deduplication is handled by the builder) diff --git a/src/api/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py index ceec6fd32..b0adbc576 100644 --- a/src/api/ingestion/infrastructure/event_handler.py +++ b/src/api/ingestion/infrastructure/event_handler.py @@ -142,6 +142,7 @@ async def handle( tenant_id=payload.get("tenant_id"), credentials=runtime_credentials or payload.get("credentials"), baseline_commit=payload.get("baseline_commit"), + pipeline_mode=pipeline_mode, ) except asyncio.CancelledError: # Propagate task cancellation so the event loop can shut down @@ -165,6 +166,23 @@ async def handle( # Ingestion succeeded — append success event outside the try block so # that an outbox write failure is not misclassified as IngestionFailed. if ingest_only: + if ingestion_result.entry_count == 0: + await self._outbox.append( + event_type="IngestionPrepared", + payload={ + "sync_run_id": sync_run_id, + "data_source_id": data_source_id, + "knowledge_graph_id": knowledge_graph_id, + "no_changes_detected": True, + "prepared_commit_sha": ingestion_result.prepared_commit_sha, + "changeset_entry_count": 0, + "occurred_at": now.isoformat(), + }, + occurred_at=now, + aggregate_type="sync_run", + aggregate_id=sync_run_id, + ) + return await self._outbox.append( event_type="IngestionPrepared", payload={ @@ -174,6 +192,7 @@ async def handle( "job_package_id": str(ingestion_result.job_package_id), "prepared_commit_sha": ingestion_result.prepared_commit_sha, "prepared_file_count": ingestion_result.branch_file_count, + "changeset_entry_count": ingestion_result.entry_count, "occurred_at": now.isoformat(), }, occurred_at=now, diff --git a/src/api/management/infrastructure/job_package_archive_reader.py b/src/api/management/infrastructure/job_package_archive_reader.py index 00e4d678d..9499ac5b4 100644 --- a/src/api/management/infrastructure/job_package_archive_reader.py +++ b/src/api/management/infrastructure/job_package_archive_reader.py @@ -1,16 +1,22 @@ -"""Read latest JobPackage identifiers for data source archive availability checks.""" +"""Read latest materializable JobPackage identifiers for archive availability checks.""" from __future__ import annotations +from pathlib import Path + from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from shared_kernel.job_package.reader import JobPackageReader +from shared_kernel.job_package.value_objects import JobPackageId + class SqlJobPackageArchiveReader: - """Resolve the latest JobPackage id emitted for one data source.""" + """Resolve the latest non-empty JobPackage id emitted for one data source.""" - def __init__(self, *, session: AsyncSession) -> None: + def __init__(self, *, session: AsyncSession, job_package_work_dir: Path) -> None: self._session = session + self._job_package_work_dir = job_package_work_dir async def latest_job_package_id_for_data_source( self, *, data_source_id: str @@ -24,13 +30,24 @@ async def latest_job_package_id_for_data_source( AND payload->>'data_source_id' = :data_source_id AND payload->>'job_package_id' IS NOT NULL ORDER BY occurred_at DESC - LIMIT 1 """ ), {"data_source_id": data_source_id}, ) - row = result.one_or_none() - if row is None or row.job_package_id is None: - return None - package_id = str(row.job_package_id).strip() - return package_id or None + for row in result.fetchall(): + package_id = str(row.job_package_id or "").strip() + if package_id and self._package_has_repository_content(package_id): + return package_id + return None + + def _package_has_repository_content(self, package_id: str) -> bool: + archive_path = self._job_package_work_dir / JobPackageId( + value=package_id + ).archive_name() + if not archive_path.is_file(): + return False + try: + manifest = JobPackageReader(archive_path).read_manifest() + except (OSError, ValueError): + return False + return manifest.entry_count > 0 diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py index 33ce5aa11..c8057ae0c 100644 --- a/src/api/management/presentation/data_sources/routes.py +++ b/src/api/management/presentation/data_sources/routes.py @@ -8,9 +8,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from extraction.infrastructure.workload_runtime_settings import ( - get_extraction_workload_runtime_settings, -) from iam.application.value_objects import CurrentUser from iam.dependencies.user import get_current_user from infrastructure.database.dependencies import get_write_session @@ -28,7 +25,10 @@ from management.infrastructure.job_package_archive_reader import SqlJobPackageArchiveReader from management.ports.exceptions import UnauthorizedError from management.ports.repositories import IDataSourceSyncRunRepository -from shared_kernel.job_package.archive_availability import job_package_archive_exists +from shared_kernel.job_package.archive_availability import ( + job_package_archive_exists, + job_package_work_dir, +) from management.presentation.data_sources.models import ( CreateDataSourceRequest, DataSourceDiffSummaryResponse, @@ -295,8 +295,11 @@ async def list_data_sources( user_id=current_user.user_id.value, kg_id=kg_id, ) - archive_reader = SqlJobPackageArchiveReader(session=session) - work_dir = get_extraction_workload_runtime_settings().job_package_work_dir + archive_reader = SqlJobPackageArchiveReader( + session=session, + job_package_work_dir=job_package_work_dir(), + ) + work_dir = job_package_work_dir() responses: list[DataSourceResponse] = [] for ds in data_sources: response = DataSourceResponse.from_domain(ds) diff --git a/src/api/shared_kernel/job_package/archive_availability.py b/src/api/shared_kernel/job_package/archive_availability.py index 1f8d484da..3bc7fd849 100644 --- a/src/api/shared_kernel/job_package/archive_availability.py +++ b/src/api/shared_kernel/job_package/archive_availability.py @@ -2,11 +2,22 @@ from __future__ import annotations +import os from pathlib import Path from shared_kernel.job_package.value_objects import JobPackageId +def job_package_work_dir() -> Path: + """Return the configured on-disk directory for JobPackage ZIP archives.""" + return Path( + os.getenv( + "KARTOGRAPH_EXTRACTION_RUNTIME_JOB_PACKAGE_WORK_DIR", + "/tmp/kartograph/job_packages", + ) + ) + + def job_package_archive_path(*, work_dir: Path, job_package_id: str) -> Path: """Return the expected on-disk path for one JobPackage archive.""" return work_dir / JobPackageId(value=job_package_id).archive_name() diff --git a/src/api/tests/unit/extraction/infrastructure/test_prepared_job_package_reader.py b/src/api/tests/unit/extraction/infrastructure/test_prepared_job_package_reader.py new file mode 100644 index 000000000..320f91146 --- /dev/null +++ b/src/api/tests/unit/extraction/infrastructure/test_prepared_job_package_reader.py @@ -0,0 +1,107 @@ +"""Unit tests for SqlPreparedJobPackageReader.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from extraction.infrastructure.prepared_job_package_reader import SqlPreparedJobPackageReader +from shared_kernel.job_package.builder import JobPackageBuilder +from shared_kernel.job_package.value_objects import ( + AdapterCheckpoint, + ChangeOperation, + ChangesetEntry, + ContentRef, + JobPackageId, + SyncMode, +) + + +def _build_package(work_dir: Path, package_id: str, *, with_file: bool) -> None: + builder = JobPackageBuilder( + data_source_id="ds-1", + knowledge_graph_id="kg-1", + sync_mode=SyncMode.FULL_REFRESH, + package_id=JobPackageId(value=package_id), + ) + if with_file: + content = b"print('hello')\n" + ref = builder.add_content(content) + builder.add_changeset_entry( + ChangesetEntry( + operation=ChangeOperation.ADD, + id="file-1", + type="io.kartograph.change.file", + path="pkg/api/example.go", + content_ref=ref, + content_type="text/plain", + metadata={}, + ) + ) + builder.set_checkpoint(AdapterCheckpoint(schema_version="1.0.0", data={"commit_sha": "abc"})) + builder.build(work_dir) + + +def _mock_session(rows: list) -> AsyncMock: + result = MagicMock() + result.fetchall.return_value = rows + session = AsyncMock() + session.execute = AsyncMock(return_value=result) + return session + + +@pytest.mark.asyncio +class TestSqlPreparedJobPackageReader: + async def test_prefers_latest_non_empty_job_package_per_data_source( + self, tmp_path: Path + ) -> None: + empty_id = "01JEMPTY000000000000000000" + full_id = "01JFULL0000000000000000000" + _build_package(tmp_path, empty_id, with_file=False) + _build_package(tmp_path, full_id, with_file=True) + + rows = [ + MagicMock( + data_source_id="ds-1", + job_package_id=empty_id, + occurred_at="2026-05-31T12:00:00Z", + ), + MagicMock( + data_source_id="ds-1", + job_package_id=full_id, + occurred_at="2026-05-31T11:00:00Z", + ), + ] + reader = SqlPreparedJobPackageReader( + session=_mock_session(rows), + job_package_work_dir=tmp_path, + ) + + package_ids = await reader.list_latest_for_knowledge_graph( + knowledge_graph_id="kg-1", + ) + + assert package_ids == (full_id,) + + async def test_skips_data_source_when_all_packages_are_empty(self, tmp_path: Path) -> None: + empty_id = "01JEMPTY000000000000000000" + _build_package(tmp_path, empty_id, with_file=False) + rows = [ + MagicMock( + data_source_id="ds-1", + job_package_id=empty_id, + occurred_at="2026-05-31T12:00:00Z", + ), + ] + reader = SqlPreparedJobPackageReader( + session=_mock_session(rows), + job_package_work_dir=tmp_path, + ) + + package_ids = await reader.list_latest_for_knowledge_graph( + knowledge_graph_id="kg-1", + ) + + assert package_ids == () diff --git a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py index 70f96778a..f9332d126 100644 --- a/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py +++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from shared_kernel.job_package.builder import JobPackageBuilder @@ -73,6 +74,56 @@ def test_materializer_does_not_discover_archives_when_package_ids_empty(tmp_path assert not any((session_root / "repository-files").iterdir()) +def _build_empty_package(work_dir: Path, package_id: str) -> None: + builder = JobPackageBuilder( + data_source_id="ds-empty", + knowledge_graph_id="kg-1", + sync_mode=SyncMode.INCREMENTAL, + package_id=JobPackageId(value=package_id), + ) + builder.set_checkpoint(AdapterCheckpoint(schema_version="1.0.0", data={"commit_sha": "abc"})) + builder.build(work_dir) + + +def test_materializer_skips_empty_job_packages(tmp_path: Path) -> None: + empty_id = "01JEMPTY000000000000000000" + full_id = "01JTESTPACK0000000000000003" + _build_empty_package(tmp_path, empty_id) + _build_package(tmp_path, full_id) + materializer = StickySessionWorkdirMaterializer(job_package_work_dir=tmp_path) + + session_root = materializer.prepare( + session_id="session-empty", + knowledge_graph_id="kg-1", + job_package_ids=(empty_id, full_id), + ) + + assert not (session_root / "repository-files" / empty_id).exists() + assert (session_root / "repository-files" / full_id / "README.md").exists() + + +def test_materializer_writes_sources_index(tmp_path: Path) -> None: + package_id = "01JTESTPACK0000000000000004" + _build_package(tmp_path, package_id) + materializer = StickySessionWorkdirMaterializer(job_package_work_dir=tmp_path) + + session_root = materializer.prepare( + session_id="session-index", + knowledge_graph_id="kg-1", + job_package_ids=(package_id,), + ) + + index_path = session_root / "sources-index.json" + assert index_path.is_file() + payload = json.loads(index_path.read_text(encoding="utf-8")) + assert payload["knowledge_graph_id"] == "kg-1" + assert len(payload["sources"]) == 1 + source = payload["sources"][0] + assert source["job_package_id"] == package_id + assert source["entry_count"] == 1 + assert source["sample_paths"] == ["README.md"] + + def test_materializer_refresh_preserves_session_root_directory(tmp_path: Path) -> None: package_id = "01JTESTPACK0000000000000002" _build_package(tmp_path, package_id) diff --git a/src/api/tests/unit/ingestion/application/test_ingestion_service.py b/src/api/tests/unit/ingestion/application/test_ingestion_service.py index 8da91a1d9..17311b46f 100644 --- a/src/api/tests/unit/ingestion/application/test_ingestion_service.py +++ b/src/api/tests/unit/ingestion/application/test_ingestion_service.py @@ -60,6 +60,7 @@ def __init__( self._result = result self._fail = fail self.last_checkpoint: AdapterCheckpoint | None = None + self.last_sync_mode: SyncMode | None = None self.last_credentials: dict[str, str] | None = None async def extract( @@ -70,6 +71,7 @@ async def extract( sync_mode: SyncMode, ) -> ExtractionResult: self.last_checkpoint = checkpoint + self.last_sync_mode = sync_mode self.last_credentials = credentials if self._fail: raise RuntimeError("credentials expired") @@ -211,3 +213,27 @@ async def test_run_uses_baseline_commit_as_checkpoint(self): assert adapter.last_checkpoint is not None assert adapter.last_checkpoint.data == {"commit_sha": "abc123"} + + async def test_ingest_only_uses_full_refresh_and_ignores_baseline(self): + """Prepare-for-agent runs must snapshot the full branch, not an empty delta.""" + result = _make_extraction_result() + adapter = _FakeAdapter(result=result) + registry: dict[str, IDatasourceAdapter] = {"github": adapter} + with tempfile.TemporaryDirectory() as tmpdir: + service = IngestionService( + adapter_registry=registry, + work_dir=Path(tmpdir), + ) + await service.run( + sync_run_id="run-001", + data_source_id="ds-001", + knowledge_graph_id="kg-001", + adapter_type="github", + connection_config={"repo": "org/repo"}, + credentials_path=None, + baseline_commit="abc123", + pipeline_mode="ingest_only", + ) + + assert adapter.last_sync_mode == SyncMode.FULL_REFRESH + assert adapter.last_checkpoint is None diff --git a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py index 07ac2d446..0c6e8336e 100644 --- a/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py +++ b/src/api/tests/unit/ingestion/infrastructure/test_ingestion_event_handler.py @@ -70,6 +70,7 @@ async def run( tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, + pipeline_mode: str = "full", ) -> IngestionRunResult: self.calls.append( { @@ -79,6 +80,7 @@ async def run( "adapter_type": adapter_type, "credentials": credentials, "baseline_commit": baseline_commit, + "pipeline_mode": pipeline_mode, } ) if self._fail: @@ -320,6 +322,7 @@ async def run( # type: ignore[override] tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, + pipeline_mode: str = "full", ) -> JobPackageId: raise RuntimeError( "github auth failed for token ghp_1234567890abcdef1234567890abcdef1234" @@ -439,6 +442,7 @@ async def run( # type: ignore[override] tenant_id: str | None = None, credentials: dict[str, str] | None = None, baseline_commit: str | None = None, + pipeline_mode: str = "full", ) -> JobPackageId: raise asyncio.CancelledError() diff --git a/src/api/tests/unit/management/presentation/test_data_sources_routes.py b/src/api/tests/unit/management/presentation/test_data_sources_routes.py index 52a92ec82..0a17d7d71 100644 --- a/src/api/tests/unit/management/presentation/test_data_sources_routes.py +++ b/src/api/tests/unit/management/presentation/test_data_sources_routes.py @@ -7,7 +7,7 @@ from __future__ import annotations from datetime import UTC, datetime -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI, status @@ -104,6 +104,16 @@ def sample_sync_run(sample_data_source: DataSource) -> DataSourceSyncRun: ) +@pytest.fixture +def mock_write_session() -> AsyncMock: + """Mock write DB session for JobPackage archive lookups.""" + session = AsyncMock() + result = MagicMock() + result.fetchall.return_value = [] + session.execute = AsyncMock(return_value=result) + return session + + @pytest.fixture def test_client( mock_ds_service: AsyncMock, @@ -111,9 +121,11 @@ def test_client( mock_diff_summary_service: AsyncMock, mock_commit_reference_service: AsyncMock, mock_current_user: CurrentUser, + mock_write_session: AsyncMock, ) -> TestClient: """Create TestClient with mocked dependencies.""" from iam.dependencies.user import get_current_user + from infrastructure.database.dependencies import get_write_session from management.dependencies.data_source import ( get_data_source_service, get_git_commit_reference_service, @@ -124,6 +136,9 @@ def test_client( app = FastAPI() + async def _override_write_session(): + yield mock_write_session + app.dependency_overrides[get_data_source_service] = lambda: mock_ds_service app.dependency_overrides[get_sync_run_repository] = lambda: mock_sync_run_repo app.dependency_overrides[get_git_diff_summary_service] = ( @@ -133,6 +148,7 @@ def test_client( lambda: mock_commit_reference_service ) app.dependency_overrides[get_current_user] = lambda: mock_current_user + app.dependency_overrides[get_write_session] = _override_write_session app.include_router(router) diff --git a/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py b/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py index 60d82e745..bd4ac7e0c 100644 --- a/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py +++ b/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py @@ -4,6 +4,8 @@ from pathlib import Path +import pytest + from shared_kernel.job_package.archive_availability import job_package_archive_exists from shared_kernel.job_package.builder import JobPackageBuilder from shared_kernel.job_package.value_objects import ( @@ -16,6 +18,14 @@ ) +def test_job_package_work_dir_defaults_to_tmp_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KARTOGRAPH_EXTRACTION_RUNTIME_JOB_PACKAGE_WORK_DIR", raising=False) + + from shared_kernel.job_package.archive_availability import job_package_work_dir + + assert job_package_work_dir() == Path("/tmp/kartograph/job_packages") + + def test_job_package_archive_exists_when_file_present(tmp_path: Path) -> None: package_id = "01JTESTPACK0000000000000099" content_bytes = b"# hello\n" From 444c2eb839aff9665d5e620a93b2510800f16ceb Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Sun, 31 May 2026 23:02:39 -0400 Subject: [PATCH 78/80] perf(ingestion): parallelize prepare sync processing Process SyncStarted outbox events with bounded concurrency and fetch GitHub blobs in parallel to reduce ingestion-context preparation time for multi-source batches. Co-authored-by: Cursor <cursoragent@cursor.com> --- src/api/infrastructure/outbox/worker.py | 64 +++++++++++++++++-- src/api/infrastructure/settings.py | 8 +++ .../infrastructure/adapters/github.py | 55 ++++++++++------ src/api/main.py | 6 +- .../unit/infrastructure/outbox/test_worker.py | 46 +++++++++++++ .../adapters/test_github_adapter.py | 55 ++++++++++++++++ 6 files changed, 207 insertions(+), 27 deletions(-) diff --git a/src/api/infrastructure/outbox/worker.py b/src/api/infrastructure/outbox/worker.py index ee9a6acd9..a36412d7e 100644 --- a/src/api/infrastructure/outbox/worker.py +++ b/src/api/infrastructure/outbox/worker.py @@ -50,6 +50,7 @@ def __init__( poll_interval_seconds: int = 30, batch_size: int = 100, max_retries: int = 5, + sync_started_max_concurrency: int = 1, ) -> None: """Initialize the worker. @@ -63,6 +64,8 @@ def __init__( poll_interval_seconds: How often to poll for missed events batch_size: Maximum entries to process per batch max_retries: Maximum retry attempts before moving to DLQ + sync_started_max_concurrency: Maximum parallel SyncStarted handlers + per batch. Other events remain serial to preserve lifecycle order. """ if session_factory is None: raise ValueError("session_factory is required") @@ -79,6 +82,11 @@ def __init__( raise ValueError(f"batch_size must be positive, got {batch_size}") if max_retries < 0: raise ValueError(f"max_retries must be non-negative, got {max_retries}") + if sync_started_max_concurrency <= 0: + raise ValueError( + "sync_started_max_concurrency must be positive, " + f"got {sync_started_max_concurrency}" + ) self._session_factory = session_factory self._handler = handler @@ -87,6 +95,7 @@ def __init__( self._poll_interval = poll_interval_seconds self._batch_size = batch_size self._max_retries = max_retries + self._sync_started_max_concurrency = sync_started_max_concurrency self._running = False self._tasks: list[asyncio.Task[None]] = [] # Used by stop() to interrupt the poll-loop's inter-batch sleep without @@ -240,17 +249,58 @@ async def _process_entries( session: AsyncSession, ) -> None: """Process a list of entries by delegating to the event handler.""" + sync_started_block: list[OutboxEntry] = [] for entry in entries: - try: - self._probe.event_dispatching(entry.id, entry.event_type) - await self._handler.handle(entry.event_type, entry.payload) + if ( + entry.event_type == "SyncStarted" + and self._sync_started_max_concurrency > 1 + ): + sync_started_block.append(entry) + continue + + if sync_started_block: + await self._process_sync_started_block(sync_started_block, session) + sync_started_block = [] + + await self._process_entry(entry, session) + + if sync_started_block: + await self._process_sync_started_block(sync_started_block, session) + + async def _process_entry(self, entry: OutboxEntry, session: AsyncSession) -> None: + """Process one outbox entry serially.""" + try: + self._probe.event_dispatching(entry.id, entry.event_type) + await self._handler.handle(entry.event_type, entry.payload) + await self._mark_processed(entry.id, session) + self._probe.event_processed(entry.id, entry.event_type) + except Exception as e: + await self._handle_processing_failure(entry, str(e), session) + + async def _process_sync_started_block( + self, + entries: list[OutboxEntry], + session: AsyncSession, + ) -> None: + """Process contiguous SyncStarted entries with bounded parallelism.""" + semaphore = asyncio.Semaphore(self._sync_started_max_concurrency) - # Mark as processed + async def _dispatch(entry: OutboxEntry) -> Exception | None: + self._probe.event_dispatching(entry.id, entry.event_type) + try: + async with semaphore: + await self._handler.handle(entry.event_type, entry.payload) + return None + except Exception as exc: # pragma: no cover - covered via caller paths + return exc + + errors = await asyncio.gather(*(_dispatch(entry) for entry in entries)) + for entry, error in zip(entries, errors, strict=True): + if error is None: await self._mark_processed(entry.id, session) self._probe.event_processed(entry.id, entry.event_type) - - except Exception as e: - await self._handle_processing_failure(entry, str(e), session) + else: + await self._handle_processing_failure(entry, str(error), session) async def _mark_processed(self, entry_id: UUID, session: AsyncSession) -> None: """Mark an entry as successfully processed.""" diff --git a/src/api/infrastructure/settings.py b/src/api/infrastructure/settings.py index 8a2e0fb7b..1b3a62a32 100644 --- a/src/api/infrastructure/settings.py +++ b/src/api/infrastructure/settings.py @@ -247,6 +247,8 @@ class OutboxWorkerSettings(BaseSettings): KARTOGRAPH_OUTBOX_ENABLED: Enable the outbox worker (default: true) KARTOGRAPH_OUTBOX_POLL_INTERVAL_SECONDS: Poll interval in seconds (default: 30) KARTOGRAPH_OUTBOX_BATCH_SIZE: Maximum entries per batch (default: 100) + KARTOGRAPH_OUTBOX_SYNC_STARTED_MAX_CONCURRENCY: Maximum concurrent + SyncStarted handlers (default: 5) """ model_config = SettingsConfigDict( @@ -278,6 +280,12 @@ class OutboxWorkerSettings(BaseSettings): ge=1, le=100, ) + sync_started_max_concurrency: int = Field( + default=5, + description="Maximum concurrent SyncStarted handlers per outbox batch", + ge=1, + le=100, + ) @lru_cache diff --git a/src/api/ingestion/infrastructure/adapters/github.py b/src/api/ingestion/infrastructure/adapters/github.py index 03d6e5204..8374e1e16 100644 --- a/src/api/ingestion/infrastructure/adapters/github.py +++ b/src/api/ingestion/infrastructure/adapters/github.py @@ -23,6 +23,7 @@ from __future__ import annotations +import asyncio import base64 import mimetypes from typing import Any @@ -74,8 +75,16 @@ class GitHubAdapter: with a custom transport for testing. """ - def __init__(self, http_client: httpx.AsyncClient | None = None) -> None: + def __init__( + self, + http_client: httpx.AsyncClient | None = None, + *, + blob_fetch_max_concurrency: int = 16, + ) -> None: + if blob_fetch_max_concurrency <= 0: + raise ValueError("blob_fetch_max_concurrency must be positive") self._http_client = http_client + self._blob_fetch_max_concurrency = blob_fetch_max_concurrency @staticmethod def _parse_connection_config( @@ -396,42 +405,50 @@ async def _fetch_file_contents( Returns: Tuple of (list of ChangesetEntry, content_blobs dict). """ - changeset_entries: list[ChangesetEntry] = [] - content_blobs: dict[str, bytes] = {} + semaphore = asyncio.Semaphore(self._blob_fetch_max_concurrency) + loaded: dict[int, tuple[ChangesetEntry, bytes]] = {} - for file_info in files: + async def _load_file(index: int, file_info: dict[str, Any]) -> None: path: str = file_info["path"] blob_sha: str = file_info["sha"] operation: ChangeOperation = file_info["operation"] previous_path: str | None = file_info.get("previous_path") - # Fetch raw content from blob - raw_bytes = await self._fetch_blob(client, headers, owner, repo, blob_sha) + async with semaphore: + raw_bytes = await self._fetch_blob(client, headers, owner, repo, blob_sha) - # Content-address the blob by its SHA-256 digest content_ref = ContentRef.from_bytes(raw_bytes) - content_blobs[content_ref.hex_digest] = raw_bytes - - # Detect content MIME type; default to octet-stream for unknown content_type, _ = mimetypes.guess_type(path) if content_type is None: content_type = "application/octet-stream" - # Build adapter-specific metadata metadata: dict[str, Any] = {} if previous_path: metadata["previous_path"] = previous_path - entry = ChangesetEntry( - operation=operation, - id=blob_sha, - type=_ENTRY_TYPE_FILE, - path=path, - content_ref=content_ref, - content_type=content_type, - metadata=metadata, + loaded[index] = ( + ChangesetEntry( + operation=operation, + id=blob_sha, + type=_ENTRY_TYPE_FILE, + path=path, + content_ref=content_ref, + content_type=content_type, + metadata=metadata, + ), + raw_bytes, ) + + await asyncio.gather( + *(_load_file(index, file_info) for index, file_info in enumerate(files)) + ) + + changeset_entries: list[ChangesetEntry] = [] + content_blobs: dict[str, bytes] = {} + for index in range(len(files)): + entry, raw_bytes = loaded[index] changeset_entries.append(entry) + content_blobs[entry.content_ref.hex_digest] = raw_bytes return changeset_entries, content_blobs diff --git a/src/api/main.py b/src/api/main.py index 93be300ea..f34d056a9 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -209,7 +209,10 @@ async def _ingest_only_archive_available( job_package_archive_exists, ) - reader = SqlJobPackageArchiveReader(session=session) + reader = SqlJobPackageArchiveReader( + session=session, + job_package_work_dir=_JOB_PACKAGE_WORK_DIR, + ) package_id = await reader.latest_job_package_id_for_data_source( data_source_id=data_source_id, ) @@ -667,6 +670,7 @@ async def kartograph_lifespan(app: FastAPI): poll_interval_seconds=outbox_settings.poll_interval_seconds, batch_size=outbox_settings.batch_size, max_retries=outbox_settings.max_retries, + sync_started_max_concurrency=outbox_settings.sync_started_max_concurrency, ) await worker.start() app.state.outbox_worker = worker diff --git a/src/api/tests/unit/infrastructure/outbox/test_worker.py b/src/api/tests/unit/infrastructure/outbox/test_worker.py index e2ba58e56..0585a5349 100644 --- a/src/api/tests/unit/infrastructure/outbox/test_worker.py +++ b/src/api/tests/unit/infrastructure/outbox/test_worker.py @@ -176,6 +176,52 @@ async def test_marks_entry_as_processed(self): # Verify session.execute was called (for mark_processed) mock_session.execute.assert_called() + @pytest.mark.asyncio + async def test_processes_sync_started_events_with_bounded_parallelism(self): + """SyncStarted events should fan out in parallel when configured.""" + mock_session = AsyncMock() + mock_probe = MagicMock() + in_flight = 0 + max_in_flight = 0 + + async def handle(event_type: str, payload: dict) -> None: + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + await asyncio.sleep(0.03) + in_flight -= 1 + + mock_handler = AsyncMock() + mock_handler.handle.side_effect = handle + + entries: list[OutboxEntry] = [] + for i in range(5): + entries.append( + OutboxEntry( + id=uuid4(), + aggregate_type="sync_run", + aggregate_id=f"01SYNC{i:02d}", + event_type="SyncStarted", + payload={"sync_run_id": f"run-{i}"}, + occurred_at=datetime(2026, 1, 8, 12, 0, 0, tzinfo=UTC), + processed_at=None, + created_at=datetime(2026, 1, 8, 12, 0, 1, tzinfo=UTC), + ) + ) + + worker = OutboxWorker( + session_factory=AsyncMock(), + handler=mock_handler, + probe=mock_probe, + event_source=None, + sync_started_max_concurrency=3, + ) + + await worker._process_entries(entries, mock_session) + + assert max_in_flight >= 2 + assert max_in_flight <= 3 + class TestOutboxWorkerLifecycle: """Tests for worker start/stop lifecycle.""" diff --git a/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py b/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py index 5f90bca74..4efa93f85 100644 --- a/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py +++ b/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py @@ -22,6 +22,7 @@ from __future__ import annotations +import asyncio import base64 import json @@ -881,3 +882,57 @@ async def test_changeset_entry_type_is_file( for entry in result.changeset_entries: assert entry.type == "io.kartograph.change.file" + + @pytest.mark.asyncio + async def test_full_refresh_fetches_blobs_with_parallelism( + self, connection_config, credentials + ): + """Blob fetches should run concurrently for better throughput.""" + max_in_flight = 0 + in_flight = 0 + + files = [ + { + "path": f"src/file_{i}.py", + "type": "blob", + "sha": f"blob{i:02d}" * 5, + } + for i in range(4) + ] + + class ConcurrentBlobTransport(httpx.AsyncBaseTransport): + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + nonlocal max_in_flight, in_flight + url_path = request.url.path + if url_path.endswith("/branches/main"): + data: dict = _branch_response(HEAD_SHA) + elif f"/git/trees/{HEAD_SHA}" in url_path: + data = _tree_response(files) + elif "/git/blobs/" in url_path: + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + await asyncio.sleep(0.03) + in_flight -= 1 + data = _blob_response(b"print('hi')\n") + else: + raise RuntimeError(f"Unexpected URL: {url_path}") + return httpx.Response( + 200, + content=json.dumps(data).encode(), + headers={"content-type": "application/json"}, + ) + + client = httpx.AsyncClient(transport=ConcurrentBlobTransport()) + adapter = GitHubAdapter(http_client=client) + + result = await adapter.extract( + connection_config=connection_config, + credentials=credentials, + checkpoint=None, + sync_mode=SyncMode.FULL_REFRESH, + ) + + assert len(result.changeset_entries) == 4 + assert max_in_flight >= 2 From a34a7ea33c6814b17550eec435bdab7de4aec331 Mon Sep 17 00:00:00 2001 From: aredenba-rh <aredenba@redhat.com> Date: Mon, 1 Jun 2026 13:16:20 -0400 Subject: [PATCH 79/80] feat(ui): add entity and relationship schema artifacts to graph management Expose separate schema-entities and schema-relationships rail items with readiness-driven status and detail panels so designers can track type coverage before transitioning. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../pages/knowledge-graphs/[kgId]/manage.vue | 86 ++++++++++++++++++- .../kg-graph-management-artifacts.test.ts | 4 +- .../knowledge-graph-manage-workspace.test.ts | 10 +++ src/dev-ui/app/utils/kgGraphManagement.ts | 20 +++++ .../app/utils/kgGraphManagementArtifacts.ts | 6 ++ 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue index 49e66a397..368c7bdd7 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue @@ -363,6 +363,8 @@ const graphManagementRailItems = computed(() => { transitionEligible: statusProjection.value.transition_eligible, blockingReasonCount: statusProjection.value.readiness.blocking_reasons.length, prepopulatedGapCount: statusProjection.value.readiness.prepopulated_types_without_instances.length, + hasMinimumEntityTypes: statusProjection.value.readiness.has_minimum_entity_types, + hasMinimumRelationshipTypes: statusProjection.value.readiness.has_minimum_relationship_types, sessionUpdatedAt: extractionSession.value?.updated_at ?? null, hasActiveSession: Boolean(extractionSession.value?.id), }) @@ -1863,7 +1865,89 @@ watch(selectedOpsDataSourceId, () => { </Card> <div id="graph-management-artifact-detail" class="graph-management-detail scroll-mt-6 space-y-6"> - <Card v-if="selectedRailItemId === 'schema-readiness'"> + <Card v-if="selectedRailItemId === 'schema-entities'"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <Box class="size-4" /> + Schema: Entities + </CardTitle> + <CardDescription> + Entity type coverage snapshot for + <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span>. + </CardDescription> + </CardHeader> + <CardContent class="space-y-3 text-sm"> + <div class="flex flex-wrap justify-end gap-2"> + <Button variant="outline" size="sm" as-child> + <NuxtLink to="/graph/schema">Open schema browser</NuxtLink> + </Button> + </div> + <div class="rounded-lg border bg-muted/30 p-3"> + <div class="flex items-center justify-between gap-2"> + <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Entity type inventory + </p> + <Badge :variant="entityTypeLabels.length > 0 ? 'default' : 'secondary'"> + {{ entityTypeLabels.length }} type(s) + </Badge> + </div> + <p + v-if="entityTypeLabels.length === 0" + class="mt-2 text-xs text-muted-foreground" + > + No entity types defined yet. Add at least one type to satisfy schema readiness. + </p> + <div v-else class="mt-2 flex flex-wrap gap-2"> + <Badge v-for="label in entityTypeLabels" :key="label" variant="outline"> + {{ label }} + </Badge> + </div> + </div> + </CardContent> + </Card> + + <Card v-else-if="selectedRailItemId === 'schema-relationships'"> + <CardHeader> + <CardTitle class="text-base flex items-center gap-2"> + <Link2 class="size-4" /> + Schema: Relationships + </CardTitle> + <CardDescription> + Relationship type coverage snapshot for + <span class="font-medium text-foreground">{{ graphManagementModeLabel }}</span>. + </CardDescription> + </CardHeader> + <CardContent class="space-y-3 text-sm"> + <div class="flex flex-wrap justify-end gap-2"> + <Button variant="outline" size="sm" as-child> + <NuxtLink to="/graph/schema">Open schema browser</NuxtLink> + </Button> + </div> + <div class="rounded-lg border bg-muted/30 p-3"> + <div class="flex items-center justify-between gap-2"> + <p class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + Relationship type inventory + </p> + <Badge :variant="relationshipTypeLabels.length > 0 ? 'default' : 'secondary'"> + {{ relationshipTypeLabels.length }} type(s) + </Badge> + </div> + <p + v-if="relationshipTypeLabels.length === 0" + class="mt-2 text-xs text-muted-foreground" + > + No relationship types defined yet. Add at least one type to satisfy schema readiness. + </p> + <div v-else class="mt-2 flex flex-wrap gap-2"> + <Badge v-for="label in relationshipTypeLabels" :key="label" variant="outline"> + {{ label }} + </Badge> + </div> + </div> + </CardContent> + </Card> + + <Card v-else-if="selectedRailItemId === 'schema-readiness'"> <CardHeader> <CardTitle class="text-base flex items-center gap-2"> <CheckCircle2 class="size-4" /> diff --git a/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts b/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts index c250bd2bb..e89b76a4e 100644 --- a/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts +++ b/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts @@ -13,6 +13,8 @@ describe('kgGraphManagementArtifacts', () => { transitionEligible: false, blockingReasonCount: 1, prepopulatedGapCount: 0, + hasMinimumEntityTypes: false, + hasMinimumRelationshipTypes: false, sessionUpdatedAt: '2026-01-01', hasActiveSession: true, }) @@ -26,7 +28,7 @@ describe('kgGraphManagementArtifacts', () => { it('resolves schema selection for the active mode', () => { expect( resolveSchemaRailSelection(null, 'initial-schema-design', items), - ).toBe('schema-readiness') + ).toBe('schema-entities') expect( resolveSchemaRailSelection('session-pointers', 'extraction-jobs', items), ).toBe('extraction-jobs-setup') diff --git a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts index 6bea76b83..19b93d99c 100644 --- a/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts +++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts @@ -439,6 +439,8 @@ describe('KG-MANAGE-008 - hybrid lower panel shared rail', () => { transitionEligible: false, blockingReasonCount: 1, prepopulatedGapCount: 0, + hasMinimumEntityTypes: false, + hasMinimumRelationshipTypes: false, sessionUpdatedAt: '2026-05-22T12:00:00Z', hasActiveSession: true, }) @@ -466,6 +468,8 @@ describe('KG-MANAGE-009 - hybrid lower panel mode-specific detail', () => { transitionEligible: true, blockingReasonCount: 0, prepopulatedGapCount: 0, + hasMinimumEntityTypes: true, + hasMinimumRelationshipTypes: true, sessionUpdatedAt: null, hasActiveSession: true, }) @@ -481,6 +485,10 @@ describe('KG-MANAGE-009 - hybrid lower panel mode-specific detail', () => { describe('KG-MANAGE-010 - schema design parity behavior', () => { it('exposes schema readiness and validation detail in initial schema design mode', () => { + expect(manageWorkspaceVue).toContain('Schema: Entities') + expect(manageWorkspaceVue).toContain('Schema: Relationships') + expect(manageWorkspaceVue).toContain("selectedRailItemId === 'schema-entities'") + expect(manageWorkspaceVue).toContain("selectedRailItemId === 'schema-relationships'") expect(manageWorkspaceVue).toContain('progressChecklist') expect(manageWorkspaceVue).toContain('Bootstrap progress checklist') expect(manageWorkspaceVue).toContain('blocking_reasons') @@ -530,6 +538,8 @@ describe('KG-MANAGE-016 - graph management top controls', () => { transitionEligible: true, blockingReasonCount: 0, prepopulatedGapCount: 0, + hasMinimumEntityTypes: true, + hasMinimumRelationshipTypes: true, sessionUpdatedAt: '2026-05-22T12:00:00Z', hasActiveSession: true, }) diff --git a/src/dev-ui/app/utils/kgGraphManagement.ts b/src/dev-ui/app/utils/kgGraphManagement.ts index 9ddecfa71..dd2f87f44 100644 --- a/src/dev-ui/app/utils/kgGraphManagement.ts +++ b/src/dev-ui/app/utils/kgGraphManagement.ts @@ -6,6 +6,8 @@ export type GraphManagementMode = | 'one-off-mutations' export type GraphManagementRailItemId = + | 'schema-entities' + | 'schema-relationships' | 'schema-readiness' | 'validation-diagnostics' | 'session-pointers' @@ -47,6 +49,8 @@ export interface GraphManagementRailInputs { transitionEligible: boolean blockingReasonCount: number prepopulatedGapCount: number + hasMinimumEntityTypes: boolean + hasMinimumRelationshipTypes: boolean sessionUpdatedAt: string | null hasActiveSession: boolean } @@ -85,6 +89,22 @@ export function buildGraphManagementRailItems( : 'in_progress' return [ + { + id: 'schema-entities', + label: 'Schema: Entities', + status: input.hasMinimumEntityTypes ? 'ready' : 'in_progress', + lastUpdated: sessionStamp, + detailHint: 'Entity type definitions and coverage snapshot.', + modes: ['initial-schema-design'], + }, + { + id: 'schema-relationships', + label: 'Schema: Relationships', + status: input.hasMinimumRelationshipTypes ? 'ready' : 'in_progress', + lastUpdated: sessionStamp, + detailHint: 'Relationship type definitions and edge coverage snapshot.', + modes: ['initial-schema-design'], + }, { id: 'schema-readiness', label: 'Schema readiness', diff --git a/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts b/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts index a93b42ad0..2e339da08 100644 --- a/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts +++ b/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts @@ -39,6 +39,12 @@ export function graphManagementArtifactRowClass(selected: boolean, done: boolean } export function graphManagementArtifactHint(item: GraphManagementRailItem): string { + if (item.id === 'schema-entities') { + return item.status === 'ready' ? 'Types available' : 'Define entities' + } + if (item.id === 'schema-relationships') { + return item.status === 'ready' ? 'Types available' : 'Define relationships' + } if (item.id === 'schema-readiness') { return item.status === 'ready' ? 'Ready to transition' : 'Bootstrap checklist' } From 226923abcfec4bc7682e6e3a1e0fa5d3b4740379 Mon Sep 17 00:00:00 2001 From: Austin Redenbaugh <aredenba@redhat.com> Date: Wed, 3 Jun 2026 00:30:11 -0400 Subject: [PATCH 80/80] Add Refresh data bulk action to KG data-sources overview (#748) Lets users reload sources, sync runs, and diff summaries via the existing silent loadDataSources path without triggering commit-refs refresh or ingestion prep. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../[kgId]/data-sources/index.vue | 23 +++++++++++++++++-- .../app/tests/kg-data-sources-phase1.test.ts | 8 ++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue index 162f0afb5..27e34198b 100644 --- a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue +++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue @@ -295,8 +295,8 @@ async function loadKnowledgeGraph() { } } -async function loadDataSources(options: { silent?: boolean } = {}) { - if (!hasTenant.value) return +async function loadDataSources(options: { silent?: boolean } = {}): Promise<boolean> { + if (!hasTenant.value) return true const silent = options.silent ?? dataSources.value.length > 0 if (silent) { refreshing.value = true @@ -324,10 +324,12 @@ async function loadDataSources(options: { silent?: boolean } = {}) { } } dataSources.value = sources + return true } catch { if (!silent) { dataSources.value = [] } + return false } finally { if (silent) { refreshing.value = false @@ -337,6 +339,13 @@ async function loadDataSources(options: { silent?: boolean } = {}) { } } +async function refreshDataSources() { + const ok = await loadDataSources({ silent: true }) + if (!ok) { + toast.error('Failed to refresh data sources') + } +} + async function ensureEntryRoute() { await loadDataSources() if (dataSources.value.length === 0) { @@ -652,6 +661,16 @@ watch(tenantVersion, async () => { </CardTitle> </div> <div class="flex flex-wrap gap-2"> + <Button + variant="outline" + size="sm" + :disabled="refreshing || checkingAllCommits || preparingAll" + @click="refreshDataSources" + > + <Loader2 v-if="refreshing" class="mr-2 size-4 animate-spin" /> + <RefreshCw v-else class="mr-2 size-4" /> + Refresh data + </Button> <Button variant="outline" size="sm" diff --git a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts index 1254800df..ec85d18c8 100644 --- a/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts +++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts @@ -40,7 +40,9 @@ describe('KG data sources phase1 layout', () => { expect(phase1Vue).toContain('step=graph-management') }) - it('renders bulk commit check and prepare actions', () => { + it('renders bulk refresh, commit check, and prepare actions', () => { + expect(phase1Vue).toContain('Refresh data') + expect(phase1Vue).toContain('refreshDataSources') expect(phase1Vue).toContain('Check for new commits') expect(phase1Vue).toContain('Prepare data sources') expect(phase1Vue).toContain('prepareAllDataSources') @@ -64,6 +66,10 @@ describe('KG data sources phase1 layout', () => { expect(phase1Vue).toContain('Updating…') }) + it('shows error toast when manual refresh fails', () => { + expect(phase1Vue).toContain("toast.error('Failed to refresh data sources')") + }) + it('shows unpulled commit columns', () => { expect(phase1Vue).toContain('Newest unpulled') expect(phase1Vue).toContain('Last extraction baseline')