diff --git a/Makefile b/Makefile
index bda68a19e..62679cad5 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"
@@ -35,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/compose.dev.yaml b/compose.dev.yaml
index e70679ff7..e48dc6de8 100644
--- a/compose.dev.yaml
+++ b/compose.dev.yaml
@@ -1,14 +1,43 @@
# 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}"
+ # 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: ${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/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/env/api.env b/env/api.env
index c909d14cf..6cdd3da20 100644
--- a/env/api.env
+++ b/env/api.env
@@ -11,5 +11,6 @@ SPICEDB_PRESHARED_KEY="changeme"
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="
+KARTOGRAPH_EXTRACTION_RUNTIME_BACKEND=memory
+KARTOGRAPH_EXTRACTION_RUNTIME_CONTAINER_ENGINE=auto
\ No newline at end of file
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",
diff --git a/skills/subagent-delivery/SKILL.md b/skills/subagent-delivery/SKILL.md
new file mode 100644
index 000000000..824d6ec4b
--- /dev/null
+++ b/skills/subagent-delivery/SKILL.md
@@ -0,0 +1,206 @@
+---
+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.
+ Supports parallel delivery waves with explicit blocker-question escalation.
+---
+
+# Subagent Delivery Protocol
+
+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:
+
+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.
+
+## 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:
+
+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.
+
+## 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.
+
+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.
+
+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:
+ - `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.
+7. If new ambiguity appears mid-implementation, invoke the Blocker Question Protocol.
+
+## 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.
+5. If any assumptions were made, include an explicit assumptions list in PR body.
+
+## 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.
+
+## 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:
+
+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.
+- 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.
+- 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--
+
+Required context files:
+- AGENTS.md
+-
+-
+-
+
+Acceptance criteria summary:
+-
+-
+
+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 #] | Branch: | PR:
diff --git a/specs/extraction/agent-sessions.spec.md b/specs/extraction/agent-sessions.spec.md
new file mode 100644
index 000000000..4f2e44a7b
--- /dev/null
+++ b/specs/extraction/agent-sessions.spec.md
@@ -0,0 +1,61 @@
+# 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
+
+#### 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.
+
+#### 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/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
new file mode 100644
index 000000000..adb760f35
--- /dev/null
+++ b/specs/extraction/operations.spec.md
@@ -0,0 +1,88 @@
+# 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: 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.
+
+#### 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/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/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..cee23c82a 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,16 @@ 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 |
+| [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.
@@ -88,3 +100,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/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/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..ed11899c5
--- /dev/null
+++ b/specs/nfr/workload-execution.spec.md
@@ -0,0 +1,73 @@
+# 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
+
+#### 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/specs/ui/experience.spec.md b/specs/ui/experience.spec.md
index eb43171e4..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
@@ -144,6 +127,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.
@@ -511,3 +512,30 @@ 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: 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..86871b238
--- /dev/null
+++ b/specs/ui/kg-manage-experience.spec.md
@@ -0,0 +1,238 @@
+# 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 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
+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`.
diff --git a/src/agent-runtime/Dockerfile b/src/agent-runtime/Dockerfile
new file mode 100644
index 000000000..035c50698
--- /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 uv.lock /runtime/
+COPY kartograph_agent_runtime /runtime/kartograph_agent_runtime
+
+RUN uv sync --frozen --no-dev
+
+ENV PATH="/runtime/.venv/bin:$PATH" \
+ PYTHONUNBUFFERED=1
+
+EXPOSE 8787
+
+HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \
+ CMD /runtime/.venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health').read()" || exit 1
+
+CMD ["/runtime/.venv/bin/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..e96be5f1a
--- /dev/null
+++ b/src/agent-runtime/kartograph_agent_runtime/executor.py
@@ -0,0 +1,305 @@
+"""Turn execution for sticky session chat using Claude Agent SDK or fallback mode."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from collections.abc import AsyncIterator
+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
+
+_DEFAULT_TURN_TIMEOUT_SECONDS = 180.0
+
+
+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, 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:
+ 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//` 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. "
+ "Prepare data sources under Graph Management β Data sources."
+ )
+
+ 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. "
+ "Re-prepare data sources under Graph Management β Data sources."
+ )
+
+ lines = [
+ "## Session workspace",
+ f"Workspace mount: `{settings.workspace_dir}`",
+ (
+ "Prepared repository files live under "
+ "`repository-files//` 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
+ if settings.vertex_enabled():
+ return "Vertex AI"
+ if settings.anthropic_api_key.strip():
+ return "Anthropic API"
+ 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,
+ message: str,
+ 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 {
+ "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.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,
+ turn_timeout_seconds=turn_timeout_seconds,
+ ):
+ 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"
+ "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:"):
+ 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]],
+ auth_mode: str,
+ turn_timeout_seconds: float,
+) -> AsyncIterator[dict[str, Any]]:
+ from claude_agent_sdk import ClaudeAgentOptions, query
+
+ 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:]
+ if isinstance(entry, dict)
+ ]
+ prompt = message
+ if history_lines:
+ prompt = "Recent conversation:\n" + "\n".join(history_lines) + f"\n\nUser: {message}"
+
+ 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"
+ 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
+ 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",
+ "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
new file mode 100644
index 000000000..7a1df58ed
--- /dev/null
+++ b/src/agent-runtime/kartograph_agent_runtime/server.py
@@ -0,0 +1,94 @@
+"""HTTP server for sticky session agent runtime."""
+
+from __future__ import annotations
+
+import json
+import logging
+from collections.abc import AsyncIterator
+from typing import Any
+
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse, StreamingResponse
+from pydantic import BaseModel, Field
+
+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()
+
+
+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)
+
+
+def _workspace_ready() -> bool:
+ marker = Path(settings.workspace_dir) / "knowledge-graph-id"
+ return marker.is_file()
+
+
+@app.get("/health")
+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}
+
+
+@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]:
+ 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
new file mode 100644
index 000000000..fd8e6048f
--- /dev/null
+++ b/src/agent-runtime/kartograph_agent_runtime/settings.py
@@ -0,0 +1,38 @@
+"""Agent runtime settings loaded from container environment."""
+
+from __future__ import annotations
+
+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."""
+
+ 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")
+ 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()
+
+ 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/thinking_stream.py b/src/agent-runtime/kartograph_agent_runtime/thinking_stream.py
new file mode 100644
index 000000000..b215b859d
--- /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: Read, Grep, Glob on workspace repository-files",
+ "Connected β working on your messageβ¦",
+ ]
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/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/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..1af437dd2
--- /dev/null
+++ b/src/agent-runtime/tests/test_executor.py
@@ -0,0 +1,103 @@
+"""Unit tests for agent runtime executor fallback mode."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+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_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:
+ 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//" 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,
+) -> 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",
+ 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/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/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/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/__init__.py b/src/api/extraction/application/__init__.py
new file mode 100644
index 000000000..407be92a9
--- /dev/null
+++ b/src/api/extraction/application/__init__.py
@@ -0,0 +1,19 @@
+"""Extraction application layer.
+
+Application services orchestrate extraction workflows using domain logic
+and port contracts. They do not directly depend on infrastructure.
+"""
+
+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",
+ "ExtractionChatTurnService",
+ "ExtractionSkillResolutionService",
+ "ResolvedExtractionSkillPack",
+]
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..d22f47a5d
--- /dev/null
+++ b/src/api/extraction/application/agent_session_service.py
@@ -0,0 +1,208 @@
+"""Application service for extraction agent session lifecycle."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+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 BootstrapIntakePath, ExtractionSessionMode
+from extraction.domain.value_objects import ExtractionSessionRunMetric
+from extraction.ports.repositories import (
+ IExtractionAgentSessionRepository,
+ IExtractionSessionRunMetricsReader,
+)
+from extraction.ports.runtime import IStickySessionRuntimeManager
+
+
+@dataclass(frozen=True)
+class ExtractionSessionHistoryRecord:
+ """Session history entry with linked run-level metrics."""
+
+ session: ExtractionAgentSession
+ run_metrics: list[ExtractionSessionRunMetric]
+
+
+class ExtractionAgentSessionService:
+ """Orchestrates session create/get/list/archive behaviors by scope."""
+
+ def __init__(
+ self,
+ 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:
+ 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,
+ 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,
+ )
+ 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),
+ }
+ 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
+
+ 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,
+ 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:
+ 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)
+
+ 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 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:
+ return None
+ if session.is_active:
+ session.archive()
+ 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/application/chat_turn_service.py b/src/api/extraction/application/chat_turn_service.py
new file mode 100644
index 000000000..84220026b
--- /dev/null
+++ b/src/api/extraction/application/chat_turn_service.py
@@ -0,0 +1,169 @@
+"""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.ports.sticky_session_runtime import IStickySessionRuntimeService
+from extraction.domain.value_objects import (
+ ExtractionSessionMode,
+ GraphManagementUiMode,
+ SessionJobPackagePhase,
+)
+from extraction.ports.chat_agent import IExtractionChatAgent
+
+
+class ExtractionChatTurnService:
+ """Coordinates sticky runtime, JobPackage gating, and agent execution."""
+
+ def __init__(
+ self,
+ *,
+ session_service: ExtractionAgentSessionService,
+ runtime_service: IStickySessionRuntimeService,
+ chat_agent: IExtractionChatAgent,
+ ) -> None:
+ self._session_service = session_service
+ self._runtime_service = runtime_service
+ self._chat_agent = chat_agent
+
+ 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,
+ *,
+ tenant_id: str,
+ 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,
+ )
+
+ 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=session,
+ ):
+ yield event
+
+ 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 "
+ "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
+
+ 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 {str(sticky.get('container_id', ''))[:8]} active",
+ ],
+ }
+
+ assistant_reply: str | None = None
+ stream_failed = False
+ 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):
+ 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"])
+ elif event.get("ok") is not True:
+ stream_failed = True
+ 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:
+ 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/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/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/application/skill_resolution_service.py b/src/api/extraction/application/skill_resolution_service.py
new file mode 100644
index 000000000..11e420157
--- /dev/null
+++ b/src/api/extraction/application/skill_resolution_service.py
@@ -0,0 +1,168 @@
+"""Skill resolution for extraction sessions."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from extraction.domain.value_objects import ExtractionSessionMode, GraphManagementUiMode
+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."
+ ),
+ "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."
+ ),
+ },
+}
+
+
+_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."""
+
+ def __init__(self, override_repository: IExtractionSkillOverrideRepository) -> None:
+ self._override_repository = override_repository
+
+ async def resolve_for_session(
+ self,
+ knowledge_graph_id: str,
+ mode: ExtractionSessionMode,
+ ) -> 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,
+ 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 ResolvedExtractionSkillPack(
+ system_prompt=str(prompt_settings["system_prompt"]),
+ prompt_hierarchy=tuple(prompt_settings["prompt_hierarchy"]),
+ guardrails=tuple(prompt_settings["guardrails"]),
+ 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/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
new file mode 100644
index 000000000..e304089d8
--- /dev/null
+++ b/src/api/extraction/application/sticky_session_runtime_service.py
@@ -0,0 +1,326 @@
+"""Prepare sticky session containers before graph-management chat turns."""
+
+from __future__ import annotations
+
+import asyncio
+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.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 (
+ 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
+from shared_kernel.container_runtime.ports import ContainerRuntimeError
+
+
+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", {})
+ 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:
+ 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)
+ 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"
+ )
+ await self._session_service.save_session(session)
+ 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 = 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=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",
+ ],
+ }
+ 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=include_job_packages,
+ )
+ session.runtime_context["workspace_materialization"] = {
+ "job_package_ids": list(package_ids),
+ }
+ yield {
+ "type": "thinking",
+ "recent": [
+ "Materializing workspace and skills for sticky container",
+ "Starting isolated Claude Agent SDK container",
+ ],
+ }
+ 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",
+ "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
new file mode 100644
index 000000000..fbd2387fe
--- /dev/null
+++ b/src/api/extraction/dependencies.py
@@ -0,0 +1,130 @@
+"""FastAPI dependencies for Extraction services."""
+
+from functools import lru_cache
+from pathlib import Path
+from typing import Annotated
+
+from fastapi import Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from extraction.application import (
+ ExtractionAgentSessionService,
+ ExtractionChatTurnService,
+ ExtractionSkillResolutionService,
+)
+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,
+ 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,
+)
+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 _build_extraction_agent_session_service(
+ session: AsyncSession,
+ *,
+ sticky_runtime_manager: IStickySessionRuntimeManager | None = None,
+) -> ExtractionAgentSessionService:
+ skill_resolution_service = ExtractionSkillResolutionService(
+ override_repository=ExtractionSkillOverrideRepository()
+ )
+ return ExtractionAgentSessionService(
+ 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_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[
+ IStickySessionRuntimeManager, Depends(get_sticky_session_runtime_manager)
+ ],
+) -> ExtractionChatTurnService:
+ """Get ExtractionChatTurnService instance."""
+ runtime_settings = get_extraction_workload_runtime_settings()
+ skill_resolution_service = ExtractionSkillResolutionService(
+ override_repository=ExtractionSkillOverrideRepository()
+ )
+ session_service = _build_extraction_agent_session_service(
+ session,
+ sticky_runtime_manager=sticky_runtime_manager,
+ )
+ bootstrap_builder = StickySessionBootstrapBuilder(
+ credential_issuer=get_workload_credential_issuer(),
+ 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),
+ ),
+ runtime_settings=runtime_settings,
+ )
+ 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,
+ 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/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..cf498a8d7
--- /dev/null
+++ b/src/api/extraction/domain/value_objects.py
@@ -0,0 +1,59 @@
+"""Value objects for Extraction session lifecycle."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import StrEnum
+
+
+class ExtractionSessionMode(StrEnum):
+ """Workspace mode for extraction agent sessions."""
+
+ 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"
+
+
+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."""
+
+ 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/__init__.py b/src/api/extraction/infrastructure/__init__.py
index e69de29bb..f8bfd2360 100644
--- a/src/api/extraction/infrastructure/__init__.py
+++ b/src/api/extraction/infrastructure/__init__.py
@@ -0,0 +1,37 @@
+"""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,
+ ExtractionSkillOverrideRepository,
+)
+from extraction.infrastructure.runtime_context_builder import (
+ FilesystemExtractionRuntimeContextBuilder,
+)
+from extraction.infrastructure.workload_runtime import (
+ InMemoryEphemeralExtractionWorkerLauncher,
+ 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..1a10f80af
--- /dev/null
+++ b/src/api/extraction/infrastructure/container_workload_runtime.py
@@ -0,0 +1,443 @@
+"""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.infrastructure.vertex_runtime_env import build_vertex_container_env
+from extraction.ports.runtime import (
+ EphemeralWorkerLaunchRequest,
+ EphemeralWorkerLaunchResult,
+ IEphemeralExtractionWorkerLauncher,
+ IStickySessionRuntimeManager,
+ ScopedWorkloadCredentials,
+ StickySessionRuntimeBootstrap,
+ 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"
+
+
+_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."""
+
+ def __init__(
+ self,
+ *,
+ container_runtime: IContainerRuntime,
+ 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",
+ vertex_project_id: str = "",
+ 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
+ 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._vertex_project_id = vertex_project_id
+ 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(
+ self,
+ *,
+ session_id: str,
+ user_id: str,
+ knowledge_graph_id: str,
+ mode: str,
+ bootstrap: StickySessionRuntimeBootstrap | None = None,
+ ) -> 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
+
+ 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)
+
+ lease = self._start_runtime(
+ session_id=session_id,
+ user_id=user_id,
+ knowledge_graph_id=knowledge_graph_id,
+ mode=mode,
+ now=now,
+ bootstrap=bootstrap,
+ )
+ self._leases[session_id] = lease
+ return lease
+
+ def reset_runtime(
+ self,
+ *,
+ session_id: str,
+ 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:
+ 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,
+ bootstrap=bootstrap,
+ )
+
+ 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 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,
+ *,
+ session_id: str,
+ user_id: str,
+ 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",
+ ]
+ )
+
+ 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:
+ 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(
+ image=self._sticky_image,
+ name=container_name,
+ env=env,
+ binds=tuple(binds),
+ network=self._container_network,
+ user=container_user,
+ 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,
+ )
+ )
+ runtime_base_url = f"http://{container_name}:{self._sticky_service_port}"
+ 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,
+ runtime_base_url=runtime_base_url,
+ )
+
+ 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/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/event_handler.py b/src/api/extraction/infrastructure/event_handler.py
index 4eb5fa33c..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
@@ -40,6 +48,10 @@ def __init__(
self,
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.
@@ -47,18 +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.
@@ -69,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
@@ -79,12 +132,43 @@ 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,
+ )
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,
+ workload_credentials=workload_credentials,
)
except Exception as exc:
await self._outbox.append(
@@ -92,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,
@@ -100,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/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/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/prepared_job_package_reader.py b/src/api/extraction/infrastructure/prepared_job_package_reader.py
new file mode 100644
index 000000000..1265dcf94
--- /dev/null
+++ b/src/api/extraction/infrastructure/prepared_job_package_reader.py
@@ -0,0 +1,83 @@
+"""SQL reader for latest prepared JobPackage identifiers without importing Management."""
+
+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 materializable JobPackage ids from outbox events for one KG."""
+
+ 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
+ ) -> tuple[str, ...]:
+ result = await self._session.execute(
+ text(
+ """
+ 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
+ AND payload->>'job_package_id' IS NOT NULL
+ ORDER BY payload->>'data_source_id', occurred_at DESC
+ """
+ ),
+ {"knowledge_graph_id": knowledge_graph_id},
+ )
+ 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/remote_sticky_container_chat_agent.py b/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py
new file mode 100644
index 000000000..34957bf45
--- /dev/null
+++ b/src/api/extraction/infrastructure/remote_sticky_container_chat_agent.py
@@ -0,0 +1,87 @@
+"""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
+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 | 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,
+ *,
+ 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:
+ 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()
+ 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/repositories/__init__.py b/src/api/extraction/infrastructure/repositories/__init__.py
new file mode 100644
index 000000000..8cf46718b
--- /dev/null
+++ b/src/api/extraction/infrastructure/repositories/__init__.py
@@ -0,0 +1,17 @@
+"""Extraction infrastructure repositories."""
+
+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",
+ "ExtractionSessionRunMetricsReader",
+ "ExtractionSkillOverrideRepository",
+]
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..01596dc64
--- /dev/null
+++ b/src/api/extraction/infrastructure/repositories/agent_session_repository.py
@@ -0,0 +1,108 @@
+"""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()
+ await self._session.commit()
+
+ 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/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/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/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/infrastructure/sticky_runtime_health.py b/src/api/extraction/infrastructure/sticky_runtime_health.py
new file mode 100644
index 000000000..65910ade6
--- /dev/null
+++ b/src/api/extraction/infrastructure/sticky_runtime_health.py
@@ -0,0 +1,59 @@
+"""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"
+ )
+
+ 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
new file mode 100644
index 000000000..c646970c0
--- /dev/null
+++ b/src/api/extraction/infrastructure/sticky_session_bootstrap_builder.py
@@ -0,0 +1,77 @@
+"""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 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,
+ *,
+ 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..bac5f08f3
--- /dev/null
+++ b/src/api/extraction/infrastructure/sticky_session_workdir_materializer.py
@@ -0,0 +1,125 @@
+"""Prepare sticky session work directories with JobPackage materialization."""
+
+from __future__ import annotations
+
+import json
+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
+
+_WORKSPACE_INDEX_FILENAME = "sources-index.json"
+
+
+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."""
+
+ 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
+ session_root.mkdir(parents=True, exist_ok=True)
+ ingestion_context_dir = session_root / "ingestion-context"
+ repository_files_dir = session_root / "repository-files"
+ _replace_directory(ingestion_context_dir)
+ _replace_directory(repository_files_dir)
+
+ discovered = (
+ self._discover_job_package_ids()
+ 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:
+ for entry_name in archive.namelist():
+ validate_zip_entry_name(entry_name)
+ archive.extract(entry_name, path=package_dir)
+
+ sample_paths: list[str] = []
+ 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))
+ 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, ...]:
+ 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)
+
+ 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/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.py b/src/api/extraction/infrastructure/workload_runtime.py
new file mode 100644
index 000000000..7544854f7
--- /dev/null
+++ b/src/api/extraction/infrastructure/workload_runtime.py
@@ -0,0 +1,222 @@
+"""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,
+ StickySessionRuntimeBootstrap,
+ 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,
+ bootstrap: StickySessionRuntimeBootstrap | None = None,
+ ) -> 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,
+ runtime_base_url="memory://sticky-runtime",
+ )
+ self._leases[session_id] = lease
+ return lease
+
+ def reset_runtime(
+ self,
+ *,
+ session_id: str,
+ 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(
+ 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
+
+ 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."""
+
+ 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,
+ extra_scopes: tuple[str, ...] = (),
+ ) -> ScopedWorkloadCredentials:
+ now = datetime.now(UTC)
+ 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=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):
+ """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/infrastructure/workload_runtime_factory.py b/src/api/extraction/infrastructure/workload_runtime_factory.py
new file mode 100644
index 000000000..8642c89f5
--- /dev/null
+++ b/src/api/extraction/infrastructure/workload_runtime_factory.py
@@ -0,0 +1,95 @@
+"""Factory helpers for extraction workload runtime adapters."""
+
+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,
+)
+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:
+ """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),
+ 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,
+ vertex_project_id=resolved.vertex_project_id,
+ 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,
+ )
+
+
+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..7c1ae8f34
--- /dev/null
+++ b/src/api/extraction/infrastructure/workload_runtime_settings.py
@@ -0,0 +1,116 @@
+"""Settings for extraction workload runtime execution."""
+
+from __future__ import annotations
+
+import os
+from functools import lru_cache
+from typing import Literal
+
+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."""
+
+ 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")
+ 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=(),
+ 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")
+ 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")
+ 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()
+
+ @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)
+ 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")
+ @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/extraction/ports/__init__.py b/src/api/extraction/ports/__init__.py
index e69de29bb..10262ea8e 100644
--- a/src/api/extraction/ports/__init__.py
+++ b/src/api/extraction/ports/__init__.py
@@ -0,0 +1,30 @@
+"""Extraction port contracts."""
+
+from extraction.ports.repositories import (
+ IExtractionAgentSessionRepository,
+ IExtractionSkillOverrideRepository,
+)
+from extraction.ports.runtime import (
+ EphemeralWorkerLaunchRequest,
+ EphemeralWorkerLaunchResult,
+ IEphemeralExtractionWorkerLauncher,
+ IStickySessionRuntimeManager,
+ IWorkloadCredentialIssuer,
+ ScopedWorkloadCredentials,
+ StickySessionRuntimeLease,
+)
+from extraction.ports.services import IExtractionService
+
+__all__ = [
+ "IExtractionService",
+ "IExtractionAgentSessionRepository",
+ "IExtractionSkillOverrideRepository",
+ "IStickySessionRuntimeManager",
+ "IEphemeralExtractionWorkerLauncher",
+ "IWorkloadCredentialIssuer",
+ "StickySessionRuntimeLease",
+ "ScopedWorkloadCredentials",
+ "EphemeralWorkerLaunchRequest",
+ "EphemeralWorkerLaunchResult",
+]
+
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/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/repositories.py b/src/api/extraction/ports/repositories.py
new file mode 100644
index 000000000..03c902fed
--- /dev/null
+++ b/src/api/extraction/ports/repositories.py
@@ -0,0 +1,52 @@
+"""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, ExtractionSessionRunMetric
+
+
+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]: ...
+
+
+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."""
+
+ async def get_overrides_for_knowledge_graph(
+ self,
+ knowledge_graph_id: str,
+ mode: ExtractionSessionMode,
+ ) -> dict[str, str]: ...
+
diff --git a/src/api/extraction/ports/runtime.py b/src/api/extraction/ports/runtime.py
new file mode 100644
index 000000000..5a46b12e8
--- /dev/null
+++ b/src/api/extraction/ports/runtime.py
@@ -0,0 +1,146 @@
+"""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
+ 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)
+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 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."""
+
+ def get_or_start_runtime(
+ self,
+ *,
+ session_id: str,
+ user_id: str,
+ knowledge_graph_id: str,
+ mode: str,
+ bootstrap: StickySessionRuntimeBootstrap | None = None,
+ ) -> 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,
+ bootstrap: StickySessionRuntimeBootstrap | None = None,
+ ) -> 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."""
+ ...
+
+ 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."""
+
+ 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/extraction/ports/services.py b/src/api/extraction/ports/services.py
index f4a62c655..851dfd3bc 100644
--- a/src/api/extraction/ports/services.py
+++ b/src/api/extraction/ports/services.py
@@ -2,7 +2,21 @@
from __future__ import annotations
-from typing import Protocol
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Protocol
+
+if TYPE_CHECKING:
+ from extraction.ports.runtime import ScopedWorkloadCredentials
+
+
+@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):
@@ -22,6 +36,8 @@ async def run(
data_source_id: str,
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.
@@ -30,6 +46,9 @@ 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.
+ 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/extraction/ports/sticky_runtime_health.py b/src/api/extraction/ports/sticky_runtime_health.py
new file mode 100644
index 000000000..c23c9d4ac
--- /dev/null
+++ b/src/api/extraction/ports/sticky_runtime_health.py
@@ -0,0 +1,23 @@
+"""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."""
+ ...
+
+ async def is_healthy(self, *, runtime_base_url: str) -> bool:
+ """Return whether the sticky runtime currently responds on /health."""
+ ...
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/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/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
new file mode 100644
index 000000000..aa7246a4e
--- /dev/null
+++ b/src/api/extraction/presentation/__init__.py
@@ -0,0 +1,16 @@
+"""Extraction presentation layer.
+
+HTTP/MCP routes for extraction session and operation workflows are defined
+here as the bounded context expands.
+"""
+
+from fastapi import APIRouter
+
+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/models.py b/src/api/extraction/presentation/models.py
new file mode 100644
index 000000000..9d57ed426
--- /dev/null
+++ b/src/api/extraction/presentation/models.py
@@ -0,0 +1,145 @@
+"""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.application.agent_session_service import ExtractionSessionHistoryRecord
+from extraction.domain.entities.agent_session import ExtractionAgentSession
+from extraction.domain.value_objects import (
+ BootstrapIntakePath,
+ ExtractionSessionMode,
+ ExtractionSessionRunMetric,
+ GraphManagementUiMode,
+)
+
+
+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):
+ """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
+
+
+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."""
+
+ selected_path: BootstrapIntakePath
+ capabilities_goals: str | None = Field(
+ 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
+
+
+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
new file mode 100644
index 000000000..4e6dba76f
--- /dev/null
+++ b/src/api/extraction/presentation/routes.py
@@ -0,0 +1,254 @@
+"""HTTP routes for extraction session lifecycle operations."""
+
+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.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
+from extraction.presentation.models import (
+ BootstrapIntakePathSelectionRequest,
+ ExtractionChatTurnRequest,
+ ExtractionSessionHistoryItemResponse,
+ ExtractionSessionHistoryResponse,
+ ExtractionSessionListResponse,
+ ExtractionSessionResponse,
+ StickyRuntimeWarmupRequest,
+)
+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.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,
+)
+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_with_runtime),
+ ],
+ 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)
+
+
+@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",
+)
+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,
+ tenant_id=current_user.tenant_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,
+)
+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/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/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/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/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/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/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/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/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/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/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/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/infrastructure/migrations/versions/fa0b1c2d3e4f_add_kg_maintenance_schedule_and_history.py b/src/api/infrastructure/migrations/versions/fa0b1c2d3e4f_add_kg_maintenance_schedule_and_history.py
new file mode 100644
index 000000000..64195440f
--- /dev/null
+++ b/src/api/infrastructure/migrations/versions/fa0b1c2d3e4f_add_kg_maintenance_schedule_and_history.py
@@ -0,0 +1,42 @@
+"""Add knowledge-graph maintenance schedule and run history columns.
+
+Revision ID: fa0b1c2d3e4f
+Revises: f8e9f0a1b2c3
+Create Date: 2026-05-14 12:00:00.000000
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "fa0b1c2d3e4f"
+down_revision = "f8e9f0a1b2c3"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ """Add maintenance schedule and run history JSONB columns."""
+ op.add_column(
+ "knowledge_graphs",
+ sa.Column("maintenance_schedule", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ )
+ op.add_column(
+ "knowledge_graphs",
+ sa.Column(
+ "maintenance_run_history",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=False,
+ server_default=sa.text("'[]'::jsonb"),
+ ),
+ )
+ op.alter_column("knowledge_graphs", "maintenance_run_history", server_default=None)
+
+
+def downgrade() -> None:
+ """Remove maintenance schedule and run history columns."""
+ op.drop_column("knowledge_graphs", "maintenance_run_history")
+ op.drop_column("knowledge_graphs", "maintenance_schedule")
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/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/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/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/application/services/ingestion_service.py b/src/api/ingestion/application/services/ingestion_service.py
index d8fa626d0..a9dd1892f 100644
--- a/src/api/ingestion/application/services/ingestion_service.py
+++ b/src/api/ingestion/application/services/ingestion_service.py
@@ -9,13 +9,14 @@
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:
from shared_kernel.credential_reader import ICredentialReader
from shared_kernel.job_package.builder import JobPackageBuilder
from shared_kernel.job_package.value_objects import (
- JobPackageId,
+ AdapterCheckpoint,
SyncMode,
)
@@ -58,7 +59,10 @@ async def run(
connection_config: dict[str, str],
credentials_path: str | None,
tenant_id: str | None = None,
- ) -> JobPackageId:
+ 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.
Args:
@@ -69,9 +73,12 @@ 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
+ IngestionRunResult with the produced JobPackage metadata
Raises:
ValueError: If the adapter_type is not registered
@@ -85,31 +92,42 @@ 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
+ 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=credentials,
- checkpoint=None, # no checkpoint support yet; always full refresh
- sync_mode=SyncMode.INCREMENTAL,
+ credentials=resolved_credentials,
+ checkpoint=checkpoint,
+ 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)
@@ -126,4 +144,15 @@ 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),
+ 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
new file mode 100644
index 000000000..819dd671e
--- /dev/null
+++ b/src/api/ingestion/application/value_objects.py
@@ -0,0 +1,17 @@
+"""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
+ 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..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(
@@ -183,12 +192,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 +222,7 @@ async def extract(
changeset_entries=changeset_entries,
content_blobs=content_blobs,
new_checkpoint=new_checkpoint,
+ branch_file_count=branch_file_count,
)
# ------------------------------------------------------------------
@@ -288,6 +302,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,
@@ -372,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/ingestion/infrastructure/event_handler.py b/src/api/ingestion/infrastructure/event_handler.py
index 0a9d02b63..b0adbc576 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.
@@ -74,8 +94,45 @@ 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:
+ 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,
+ "prepared_commit_sha": payload.get(
+ "tracked_branch_head_commit"
+ ),
+ "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:
- 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,
@@ -83,6 +140,9 @@ async def handle(
connection_config=payload.get("connection_config", {}),
credentials_path=payload.get("credentials_path"),
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
@@ -94,7 +154,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,
@@ -105,16 +165,51 @@ 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:
+ 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={
+ "sync_run_id": sync_run_id,
+ "data_source_id": data_source_id,
+ "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.branch_file_count,
+ "changeset_entry_count": ingestion_result.entry_count,
+ "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(ingestion_result.job_package_id),
+ "occurred_at": now.isoformat(),
+ },
+ occurred_at=now,
+ aggregate_type="sync_run",
+ aggregate_id=sync_run_id,
+ )
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/ingestion/ports/services.py b/src/api/ingestion/ports/services.py
index c6306087f..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
@@ -23,7 +24,9 @@ async def run(
connection_config: dict[str, str],
credentials_path: str | None,
tenant_id: str | None = None,
- ) -> JobPackageId:
+ credentials: dict[str, str] | None = None,
+ baseline_commit: str | None = None,
+ ) -> IngestionRunResult:
"""Run the ingestion pipeline.
Args:
@@ -33,9 +36,11 @@ 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
+ 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 074b0c232..f34d056a9 100644
--- a/src/api/main.py
+++ b/src/api/main.py
@@ -2,17 +2,21 @@
import asyncio
from contextlib import asynccontextmanager
+import os
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
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,
@@ -24,6 +28,7 @@
get_cors_settings,
get_database_settings,
get_iam_settings,
+ get_management_settings,
get_oidc_settings,
get_outbox_worker_settings,
get_spicedb_settings,
@@ -48,9 +53,13 @@
)
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
+_EXTRACTION_SKILLS_DIR = Path(
+ os.getenv("KARTOGRAPH_EXTRACTION_SKILLS_DIR", "/app/skills")
+)
# Scheduler polling interval (seconds)
_SCHEDULER_POLL_INTERVAL_SECONDS = 60
@@ -78,6 +87,7 @@ class _SessionedSyncLifecycleHandler:
{
"SyncStarted",
"JobPackageProduced",
+ "IngestionPrepared",
"IngestionFailed",
"MutationLogProduced",
"ExtractionFailed",
@@ -134,18 +144,99 @@ 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 = "/".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_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 _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,
+ 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,
+ )
+ 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 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,
+ )
+ from management.infrastructure.repositories.fernet_secret_store import (
+ FernetSecretStore,
+ )
async with self._session_factory() as session:
outbox = OutboxRepository(session=session)
- from ingestion.infrastructure.adapters.github import GitHubAdapter
- from infrastructure.settings import get_management_settings
- from management.infrastructure.repositories.fernet_secret_store import (
- FernetSecretStore,
- )
+ ds_repo = DataSourceRepository(session=session, outbox=outbox)
credential_reader = None
if payload.get("credentials_path"):
@@ -175,7 +266,63 @@ 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", ""))
+ 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:
+ pipeline_mode = str(payload.get("pipeline_mode", "full"))
+ if pipeline_mode == "ingest_only":
+ 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:
+ enriched_payload["baseline_commit"] = baseline_commit
+
+ if ds.credentials_path and tenant_id and credential_reader is not None:
+ try:
+ credentials = await credential_reader.retrieve(
+ path=ds.credentials_path,
+ tenant_id=tenant_id,
+ )
+ except KeyError:
+ 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)
+ baseline_commit = enriched_payload.get("baseline_commit")
+ if (
+ isinstance(baseline_commit, str)
+ and baseline_commit
+ and baseline_commit == tracked_head
+ ):
+ 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,
+ enriched_payload,
+ runtime_credentials=credentials,
+ )
await session.commit()
@@ -192,6 +339,8 @@ async def run(
data_source_id: str,
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. "
@@ -217,16 +366,49 @@ 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_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 (
+ 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,
+ )
extraction_handler = ExtractionEventHandler(
extraction_service=self._extraction_service,
outbox=outbox,
+ runtime_context_builder=runtime_context_builder,
+ credential_issuer=get_workload_credential_issuer(),
+ worker_launcher=create_ephemeral_extraction_worker_launcher(),
+ )
+
+ 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()
@@ -238,7 +420,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."
@@ -488,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
@@ -562,6 +745,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/management/application/services/data_source_service.py b/src/api/management/application/services/data_source_service.py
index a64490357..87d8efecf 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.
@@ -455,6 +465,81 @@ async def update_ontology(
return ds
+ async def refresh_commit_references(
+ self,
+ user_id: str,
+ ds_id: str,
+ tracked_branch_head_commit: str,
+ ) -> DataSource:
+ """Persist the latest tracked branch head for a Git-backed data source.
+
+ 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,
+ 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")
+
+ ds.tracked_branch_head_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,
@@ -514,12 +599,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
@@ -568,10 +657,104 @@ 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()
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/application/services/knowledge_graph_service.py b/src/api/management/application/services/knowledge_graph_service.py
index e32bac3b7..afd20c6ba 100644
--- a/src/api/management/application/services/knowledge_graph_service.py
+++ b/src/api/management/application/services/knowledge_graph_service.py
@@ -6,15 +6,31 @@
from __future__ import annotations
+from datetime import UTC, datetime
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from croniter import CroniterBadCronError, croniter
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
+from ulid import ULID
from management.application.observability import (
DefaultKnowledgeGraphServiceProbe,
KnowledgeGraphServiceProbe,
)
from management.domain.aggregates import KnowledgeGraph
-from management.domain.value_objects import KnowledgeGraphId, OntologyConfig
+from management.domain.entities.data_source_sync_run import DataSourceSyncRun
+from management.domain.value_objects import (
+ KnowledgeGraphMaintenanceRunOutcome,
+ KnowledgeGraphMaintenanceRunRecord,
+ KnowledgeGraphMaintenanceSchedule,
+ KnowledgeGraphId,
+ KnowledgeGraphWorkspaceStatus,
+ OntologyConfig,
+ WorkspaceMode,
+ WorkspaceReadinessStatus,
+ WorkspaceSessionPointers,
+)
from management.ports.exceptions import (
DuplicateKnowledgeGraphNameError,
KnowledgeGraphNotFoundError,
@@ -22,8 +38,10 @@
)
from management.ports.repositories import (
IDataSourceRepository,
+ 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 (
@@ -50,7 +68,9 @@ def __init__(
scope_to_tenant: str,
probe: KnowledgeGraphServiceProbe | None = None,
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.
@@ -62,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
@@ -69,7 +90,223 @@ def __init__(
self._scope_to_tenant = scope_to_tenant
self._probe = probe or DefaultKnowledgeGraphServiceProbe()
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,
+ *,
+ cron_expression: str,
+ timezone_name: str,
+ now_utc: datetime | None = None,
+ ) -> datetime:
+ """Compute next scheduled runtime in UTC from cron + timezone."""
+ if not croniter.is_valid(cron_expression):
+ raise ValueError(f"Invalid cron expression: {cron_expression!r}")
+ try:
+ tz = ZoneInfo(timezone_name)
+ except ZoneInfoNotFoundError as exc:
+ raise ValueError(f"Unknown timezone: {timezone_name!r}") from exc
+
+ now_utc = now_utc or datetime.now(UTC)
+ local_now = now_utc.astimezone(tz)
+ try:
+ itr = croniter(cron_expression, local_now)
+ next_local = itr.get_next(datetime)
+ except (CroniterBadCronError, ValueError) as exc:
+ raise ValueError(f"Invalid cron expression: {cron_expression!r}") from exc
+
+ if next_local.tzinfo is None:
+ next_local = next_local.replace(tzinfo=tz)
+ return next_local.astimezone(UTC)
+
+ async def _get_tenant_scoped_kg(
+ self, *, kg_id: str, user_id: str, permission: Permission
+ ) -> KnowledgeGraph:
+ """Resolve KG with tenant and authz checks."""
+ has_permission = await self._check_permission(
+ user_id=user_id,
+ resource_type=ResourceType.KNOWLEDGE_GRAPH,
+ resource_id=kg_id,
+ permission=permission,
+ )
+ if not has_permission:
+ self._probe.permission_denied(
+ user_id=user_id,
+ resource_id=kg_id,
+ permission=permission,
+ )
+ raise UnauthorizedError(
+ f"User {user_id} lacks {permission.value} 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")
+ return kg
+
+ async def get_maintenance_schedule(
+ self, *, user_id: str, kg_id: str
+ ) -> KnowledgeGraphMaintenanceSchedule:
+ """Return KG-level maintenance schedule config."""
+ kg = await self._get_tenant_scoped_kg(
+ kg_id=kg_id,
+ user_id=user_id,
+ permission=Permission.VIEW,
+ )
+ return kg.maintenance_schedule or KnowledgeGraphMaintenanceSchedule(
+ enabled=False,
+ cron_expression="0 2 * * *",
+ timezone_name="UTC",
+ next_run_at=None,
+ )
+
+ async def upsert_maintenance_schedule(
+ self,
+ *,
+ user_id: str,
+ kg_id: str,
+ cron_expression: str,
+ timezone_name: str,
+ enabled: bool,
+ ) -> KnowledgeGraphMaintenanceSchedule:
+ """Create or update KG-level maintenance schedule configuration."""
+ kg = await self._get_tenant_scoped_kg(
+ kg_id=kg_id,
+ user_id=user_id,
+ permission=Permission.MANAGE,
+ )
+ next_run_at = (
+ self._compute_next_run_at_utc(
+ cron_expression=cron_expression,
+ timezone_name=timezone_name,
+ )
+ if enabled
+ else None
+ )
+ schedule = KnowledgeGraphMaintenanceSchedule(
+ enabled=enabled,
+ cron_expression=cron_expression,
+ timezone_name=timezone_name,
+ next_run_at=next_run_at,
+ )
+ kg.set_maintenance_schedule(schedule)
+ await self._kg_repo.save(kg)
+ await self._session.commit()
+ return schedule
+
+ async def list_maintenance_runs(
+ self, *, user_id: str, kg_id: str, limit: int = 20
+ ) -> list[KnowledgeGraphMaintenanceRunRecord]:
+ """List persisted maintenance run outcomes for a KG."""
+ kg = await self._get_tenant_scoped_kg(
+ kg_id=kg_id,
+ user_id=user_id,
+ permission=Permission.VIEW,
+ )
+ capped_limit = max(1, min(limit, 100))
+ return list(kg.maintenance_run_history[-capped_limit:])[::-1]
+
+ async def trigger_maintenance_run(
+ self, *, user_id: str, kg_id: str
+ ) -> KnowledgeGraphMaintenanceRunRecord:
+ """Trigger maintenance orchestration across all data sources in a KG."""
+ kg = await self._get_tenant_scoped_kg(
+ kg_id=kg_id,
+ user_id=user_id,
+ permission=Permission.MANAGE,
+ )
+ if self._ds_repo is None:
+ raise ValueError("Data source repository is not configured")
+
+ data_sources = await self._ds_repo.find_by_knowledge_graph(kg_id)
+ run_id = str(ULID())
+ now = datetime.now(UTC)
+
+ if not data_sources:
+ run = KnowledgeGraphMaintenanceRunRecord(
+ run_id=run_id,
+ triggered_at=now,
+ outcome=KnowledgeGraphMaintenanceRunOutcome.PREFLIGHT_FAILED,
+ message="No data sources connected to this knowledge graph",
+ )
+ kg.append_maintenance_run(run)
+ await self._kg_repo.save(kg)
+ await self._session.commit()
+ return run
+
+ changed_sources = [
+ ds
+ for ds in data_sources
+ if ds.tracked_branch_head_commit is not None
+ and ds.last_extraction_baseline_commit is not None
+ and ds.tracked_branch_head_commit != ds.last_extraction_baseline_commit
+ ]
+ target_data_source_ids = tuple(ds.id.value for ds in data_sources)
+
+ if not changed_sources:
+ run = KnowledgeGraphMaintenanceRunRecord(
+ run_id=run_id,
+ triggered_at=now,
+ outcome=KnowledgeGraphMaintenanceRunOutcome.NO_CHANGES,
+ message="No source commit delta detected across connected data sources",
+ target_data_source_ids=target_data_source_ids,
+ )
+ kg.append_maintenance_run(run)
+ await self._kg_repo.save(kg)
+ await self._session.commit()
+ return run
+
+ if self._sync_run_repo is None:
+ run = KnowledgeGraphMaintenanceRunRecord(
+ run_id=run_id,
+ triggered_at=now,
+ outcome=KnowledgeGraphMaintenanceRunOutcome.LAUNCH_FAILED,
+ message="Sync run repository is not configured",
+ target_data_source_ids=tuple(ds.id.value for ds in changed_sources),
+ )
+ kg.append_maintenance_run(run)
+ await self._kg_repo.save(kg)
+ await self._session.commit()
+ return run
+
+ try:
+ for data_source in changed_sources:
+ sync_run_id = str(ULID())
+ sync_run = DataSourceSyncRun(
+ id=sync_run_id,
+ data_source_id=data_source.id.value,
+ status="pending",
+ started_at=now,
+ completed_at=None,
+ error=None,
+ created_at=now,
+ )
+ await self._sync_run_repo.save(sync_run)
+ data_source.request_sync(sync_run_id=sync_run_id, requested_by=user_id)
+ await self._ds_repo.save(data_source)
+
+ run = KnowledgeGraphMaintenanceRunRecord(
+ run_id=run_id,
+ triggered_at=now,
+ outcome=KnowledgeGraphMaintenanceRunOutcome.STARTED,
+ message="Scheduled maintenance sync runs started",
+ target_data_source_ids=tuple(ds.id.value for ds in changed_sources),
+ )
+ except Exception as exc:
+ run = KnowledgeGraphMaintenanceRunRecord(
+ run_id=run_id,
+ triggered_at=now,
+ outcome=KnowledgeGraphMaintenanceRunOutcome.LAUNCH_FAILED,
+ message=f"Failed to launch maintenance syncs: {exc}",
+ target_data_source_ids=tuple(ds.id.value for ds in changed_sources),
+ )
+
+ kg.append_maintenance_run(run)
+ await self._kg_repo.save(kg)
+ await self._session.commit()
+ return run
async def _check_permission(
self,
@@ -531,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,
@@ -576,7 +813,174 @@ 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 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 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
+ ) -> WorkspaceReadinessStatus:
+ """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 ontology is not None:
+ prepopulated_without_instances = tuple(
+ node_type.label
+ for node_type in 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}"
+ )
+
+ return WorkspaceReadinessStatus(
+ 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(
+ 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
+
+ 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
+ )
+
+ 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 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")
+
+ 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
+ )
+ 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")
+
+ 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(
+ 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/dependencies/data_source.py b/src/api/management/dependencies/data_source.py
index c0d6a2765..911703851 100644
--- a/src/api/management/dependencies/data_source.py
+++ b/src/api/management/dependencies/data_source.py
@@ -17,6 +17,10 @@
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,
DataSourceSyncRunRepository,
@@ -78,3 +82,37 @@ 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,
+ )
+
+
+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/dependencies/knowledge_graph.py b/src/api/management/dependencies/knowledge_graph.py
index f59264ab0..03ab069f4 100644
--- a/src/api/management/dependencies/knowledge_graph.py
+++ b/src/api/management/dependencies/knowledge_graph.py
@@ -21,9 +21,13 @@
)
from management.infrastructure.repositories import (
DataSourceRepository,
+ DataSourceSyncRunRepository,
FernetSecretStore,
KnowledgeGraphRepository,
)
+from infrastructure.canonical_schema.graph_canonical_schema_repository import (
+ GraphCanonicalSchemaRepository,
+)
from shared_kernel.authorization.protocols import AuthorizationProvider
@@ -46,6 +50,7 @@ def get_knowledge_graph_service(
outbox = OutboxRepository(session=session)
kg_repo = KnowledgeGraphRepository(session=session, outbox=outbox)
ds_repo = DataSourceRepository(session=session, outbox=outbox)
+ sync_run_repo = DataSourceSyncRunRepository(session=session)
encryption_keys = settings.encryption_key.get_secret_value().split(",")
secret_store = FernetSecretStore(
session=session,
@@ -55,8 +60,10 @@ def get_knowledge_graph_service(
session=session,
knowledge_graph_repository=kg_repo,
data_source_repository=ds_repo,
+ sync_run_repository=sync_run_repo,
secret_store=secret_store,
authz=authz,
scope_to_tenant=current_user.tenant_id.value,
probe=DefaultKnowledgeGraphServiceProbe(),
+ canonical_schema_repository=GraphCanonicalSchemaRepository(session),
)
diff --git a/src/api/management/domain/aggregates/data_source.py b/src/api/management/domain/aggregates/data_source.py
index 431eb4e12..075ecf049 100644
--- a/src/api/management/domain/aggregates/data_source.py
+++ b/src/api/management/domain/aggregates/data_source.py
@@ -63,6 +63,11 @@ 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
+ 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(
@@ -308,6 +313,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.
@@ -318,6 +324,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
@@ -335,6 +342,7 @@ def request_sync(
credentials_path=self.credentials_path,
occurred_at=datetime.now(UTC),
requested_by=requested_by,
+ pipeline_mode=pipeline_mode,
)
)
@@ -356,6 +364,43 @@ 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 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
+ self.clone_head_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/domain/aggregates/knowledge_graph.py b/src/api/management/domain/aggregates/knowledge_graph.py
index 63d10cefb..fbc3caa38 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,
@@ -15,12 +17,19 @@
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 (
+ KnowledgeGraphMaintenanceRunRecord,
+ KnowledgeGraphMaintenanceSchedule,
+ KnowledgeGraphId,
+ OntologyConfig,
+ WorkspaceMode,
+)
if TYPE_CHECKING:
from management.domain.events import DomainEvent
@@ -51,6 +60,14 @@ class KnowledgeGraph:
created_at: datetime
updated_at: datetime
ontology: OntologyConfig | None = field(default=None)
+ maintenance_schedule: KnowledgeGraphMaintenanceSchedule | None = field(default=None)
+ maintenance_run_history: tuple[KnowledgeGraphMaintenanceRunRecord, ...] = field(
+ default_factory=tuple
+ )
+ 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,
@@ -63,6 +80,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 +248,38 @@ def clear_ontology(self) -> None:
self.ontology = None
self.updated_at = datetime.now(UTC)
+ def set_maintenance_schedule(self, schedule: KnowledgeGraphMaintenanceSchedule) -> None:
+ """Persist KG-level maintenance schedule configuration."""
+ if self._deleted:
+ raise AggregateDeletedError(
+ "Cannot set maintenance schedule on a deleted knowledge graph"
+ )
+ self.maintenance_schedule = schedule
+ self.updated_at = datetime.now(UTC)
+
+ def append_maintenance_run(
+ self, run: KnowledgeGraphMaintenanceRunRecord, *, max_history: int = 50
+ ) -> None:
+ """Append a maintenance orchestration run to KG-scoped history."""
+ if self._deleted:
+ raise AggregateDeletedError(
+ "Cannot append maintenance history on a deleted knowledge graph"
+ )
+ history = [*self.maintenance_run_history, run]
+ self.maintenance_run_history = tuple(history[-max_history:])
+ self.updated_at = datetime.now(UTC)
+
+ 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/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/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..3c802a6dc 100644
--- a/src/api/management/domain/entities/data_source_sync_run.py
+++ b/src/api/management/domain/entities/data_source_sync_run.py
@@ -4,14 +4,81 @@
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"})
+TERMINAL_STATUSES = frozenset({"ingested", "completed", "failed"})
VALID_STATUSES = frozenset(
- {"pending", "ingesting", "ai_extracting", "applying", "completed", "failed"}
+ {
+ "pending",
+ "ingesting",
+ "ai_extracting",
+ "applying",
+ "ingested",
+ "completed",
+ "failed",
+ }
)
+@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.
@@ -24,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
@@ -41,6 +109,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/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/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..7c0a16605 100644
--- a/src/api/management/domain/value_objects.py
+++ b/src/api/management/domain/value_objects.py
@@ -94,6 +94,134 @@ 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 WorkspaceReadinessStatus:
+ """Readiness flags used to determine bootstrap transition eligibility."""
+
+ 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:
+ """Return true when all readiness checks pass."""
+ return (
+ self.has_minimum_entity_types
+ and self.has_minimum_relationship_types
+ and self.prepopulated_types_ready
+ and not self.prepopulated_types_without_instances
+ )
+
+
+@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
+
+
+class KnowledgeGraphMaintenanceRunOutcome(StrEnum):
+ """Allowed outcomes for a KG-scoped maintenance orchestration attempt."""
+
+ STARTED = "started"
+ NO_CHANGES = "no-changes"
+ PREFLIGHT_FAILED = "preflight-failed"
+ LAUNCH_FAILED = "launch-failed"
+
+
+@dataclass(frozen=True)
+class KnowledgeGraphMaintenanceSchedule:
+ """Knowledge-graph level maintenance schedule configuration."""
+
+ enabled: bool
+ cron_expression: str
+ timezone_name: str
+ next_run_at: datetime | None = None
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize to JSON-compatible dictionary."""
+ return {
+ "enabled": self.enabled,
+ "cron_expression": self.cron_expression,
+ "timezone_name": self.timezone_name,
+ "next_run_at": (
+ self.next_run_at.isoformat() if self.next_run_at is not None else None
+ ),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "KnowledgeGraphMaintenanceSchedule":
+ """Reconstruct schedule from persisted JSON dictionary."""
+ next_run_at_raw = data.get("next_run_at")
+ next_run_at = (
+ datetime.fromisoformat(str(next_run_at_raw))
+ if next_run_at_raw is not None
+ else None
+ )
+ return cls(
+ enabled=bool(data.get("enabled", False)),
+ cron_expression=str(data.get("cron_expression", "0 2 * * *")),
+ timezone_name=str(data.get("timezone_name", "UTC")),
+ next_run_at=next_run_at,
+ )
+
+
+@dataclass(frozen=True)
+class KnowledgeGraphMaintenanceRunRecord:
+ """Immutable audit record for a KG maintenance orchestration attempt."""
+
+ run_id: str
+ triggered_at: datetime
+ outcome: KnowledgeGraphMaintenanceRunOutcome
+ message: str | None = None
+ target_data_source_ids: tuple[str, ...] = field(default_factory=tuple)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Serialize to JSON-compatible dictionary."""
+ return {
+ "run_id": self.run_id,
+ "triggered_at": self.triggered_at.isoformat(),
+ "outcome": self.outcome.value,
+ "message": self.message,
+ "target_data_source_ids": list(self.target_data_source_ids),
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "KnowledgeGraphMaintenanceRunRecord":
+ """Reconstruct a run record from persisted JSON dictionary."""
+ return cls(
+ run_id=str(data["run_id"]),
+ triggered_at=datetime.fromisoformat(str(data["triggered_at"])),
+ outcome=KnowledgeGraphMaintenanceRunOutcome(str(data["outcome"])),
+ message=(str(data["message"]) if data.get("message") is not None else None),
+ target_data_source_ids=tuple(
+ str(ds_id) for ds_id in data.get("target_data_source_ids", [])
+ ),
+ )
+
+
@dataclass(frozen=True)
class Schedule:
"""Schedule configuration for data source synchronization.
@@ -276,11 +404,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."""
@@ -289,6 +421,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
@@ -299,6 +433,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/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/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/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/infrastructure/job_package_archive_reader.py b/src/api/management/infrastructure/job_package_archive_reader.py
new file mode 100644
index 000000000..9499ac5b4
--- /dev/null
+++ b/src/api/management/infrastructure/job_package_archive_reader.py
@@ -0,0 +1,53 @@
+"""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 non-empty JobPackage id emitted for one data source."""
+
+ 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
+ ) -> 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
+ """
+ ),
+ {"data_source_id": data_source_id},
+ )
+ 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/infrastructure/models/data_source.py b/src/api/management/infrastructure/models/data_source.py
index bbbc32e4d..737a68d7d 100644
--- a/src/api/management/infrastructure/models/data_source.py
+++ b/src/api/management/infrastructure/models/data_source.py
@@ -42,6 +42,15 @@ 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
+ )
+ 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/models/data_source_sync_run.py b/src/api/management/infrastructure/models/data_source_sync_run.py
index 4e92dee98..2af41a7bd 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
@@ -38,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)
"""
@@ -69,13 +71,16 @@ 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"),
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/models/knowledge_graph.py b/src/api/management/infrastructure/models/knowledge_graph.py
index 36a1d70bd..e6eec1dd4 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,7 +31,28 @@ 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,
+ )
+ 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)
+ maintenance_schedule: Mapped[dict | None] = mapped_column(
+ JSONB, nullable=True, default=None
+ )
+ maintenance_run_history: Mapped[list[dict]] = mapped_column(
+ JSONB, nullable=False, default=list
+ )
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_knowledge_graphs_tenant_name"),
diff --git a/src/api/management/infrastructure/repositories/data_source_repository.py b/src/api/management/infrastructure/repositories/data_source_repository.py
index 925623c95..f4e650b3c 100644
--- a/src/api/management/infrastructure/repositories/data_source_repository.py
+++ b/src/api/management/infrastructure/repositories/data_source_repository.py
@@ -80,6 +80,13 @@ 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.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:
@@ -94,6 +101,13 @@ 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,
+ 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,
@@ -207,5 +221,10 @@ 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,
+ 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/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/repositories/knowledge_graph_repository.py b/src/api/management/infrastructure/repositories/knowledge_graph_repository.py
index abb5aff83..e9e12ab97 100644
--- a/src/api/management/infrastructure/repositories/knowledge_graph_repository.py
+++ b/src/api/management/infrastructure/repositories/knowledge_graph_repository.py
@@ -13,7 +13,13 @@
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 (
+ KnowledgeGraphMaintenanceRunRecord,
+ KnowledgeGraphMaintenanceSchedule,
+ KnowledgeGraphId,
+ OntologyConfig,
+ WorkspaceMode,
+)
from management.infrastructure.models import KnowledgeGraphModel
from management.infrastructure.observability import (
DefaultKnowledgeGraphRepositoryProbe,
@@ -67,7 +73,25 @@ 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.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
+ model.maintenance_schedule = (
+ knowledge_graph.maintenance_schedule.to_dict()
+ if knowledge_graph.maintenance_schedule is not None
+ else None
+ )
+ model.maintenance_run_history = [
+ run.to_dict() for run in knowledge_graph.maintenance_run_history
+ ]
else:
model = KnowledgeGraphModel(
id=knowledge_graph.id.value,
@@ -75,8 +99,26 @@ 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,
+ 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,
+ maintenance_schedule=(
+ knowledge_graph.maintenance_schedule.to_dict()
+ if knowledge_graph.maintenance_schedule is not None
+ else None
+ ),
+ maintenance_run_history=[
+ run.to_dict() for run in knowledge_graph.maintenance_run_history
+ ],
)
self._session.add(model)
@@ -209,6 +251,15 @@ def _to_domain(self, model: KnowledgeGraphModel) -> KnowledgeGraph:
ontology: OntologyConfig | None = None
if model.ontology is not None:
ontology = OntologyConfig.from_dict(model.ontology)
+ maintenance_schedule: KnowledgeGraphMaintenanceSchedule | None = None
+ if model.maintenance_schedule is not None:
+ maintenance_schedule = KnowledgeGraphMaintenanceSchedule.from_dict(
+ model.maintenance_schedule
+ )
+ maintenance_run_history = tuple(
+ KnowledgeGraphMaintenanceRunRecord.from_dict(raw_run)
+ for raw_run in (model.maintenance_run_history or [])
+ )
return KnowledgeGraph(
id=KnowledgeGraphId(value=model.id),
@@ -219,4 +270,12 @@ def _to_domain(self, model: KnowledgeGraphModel) -> KnowledgeGraph:
created_at=model.created_at,
updated_at=model.updated_at,
ontology=ontology,
+ maintenance_schedule=maintenance_schedule,
+ maintenance_run_history=maintenance_run_history,
+ 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/infrastructure/sync_lifecycle_handler.py b/src/api/management/infrastructure/sync_lifecycle_handler.py
index 54bcf62c7..97ebe2927 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
@@ -21,6 +22,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:
@@ -42,6 +44,7 @@
"JobPackageProduced": "ai_extracting",
"MutationLogProduced": "applying",
"MutationsApplied": "completed",
+ "IngestionPrepared": "ingested",
}
_SUPPORTED_EVENTS = frozenset(_STATUS_MAP.keys()) | _FAILURE_EVENTS
@@ -118,10 +121,55 @@ 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."
+ )
+ 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"
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:
+ 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 +183,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()
@@ -155,4 +233,32 @@ 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)
+
+ 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/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/management/presentation/data_sources/models.py b/src/api/management/presentation/data_sources/models.py
index 192f52c41..699fbbf64 100644
--- a/src/api/management/presentation/data_sources/models.py
+++ b/src/api/management/presentation/data_sources/models.py
@@ -3,11 +3,16 @@
from __future__ import annotations
from datetime import datetime
+from typing import Literal
from pydantic import BaseModel, Field
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
@@ -189,6 +194,41 @@ 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"
+ )
+ 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="Total files on the tracked branch at the last prepare commit",
+ )
+ 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"
+ ),
+ )
+ 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)",
+ )
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 +254,14 @@ 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,
+ 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,
ontology=(
@@ -233,6 +281,81 @@ 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."""
+
+ 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 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."""
@@ -240,13 +363,39 @@ 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(
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"
+ )
+ 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"
+ )
+ 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"
+ )
+ 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"
)
@@ -268,10 +417,65 @@ 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
+ ),
+ 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
+ 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
+ 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,
)
+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.
@@ -293,6 +497,34 @@ 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"
+ )
+ 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="Total files on the tracked branch at the last prepare commit",
+ )
+ 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)",
+ )
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 +557,14 @@ 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,
+ 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,
ontology=(
diff --git a/src/api/management/presentation/data_sources/routes.py b/src/api/management/presentation/data_sources/routes.py
index c268f9a7c..c8057ae0c 100644
--- a/src/api/management/presentation/data_sources/routes.py
+++ b/src/api/management/presentation/data_sources/routes.py
@@ -2,26 +2,45 @@
from __future__ import annotations
+from pathlib import Path
from typing import Annotated
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.ext.asyncio import AsyncSession
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,
+ 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.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,
+ job_package_work_dir,
+)
from management.presentation.data_sources.models import (
CreateDataSourceRequest,
+ DataSourceDiffSummaryResponse,
DataSourceListResponse,
DataSourceResponse,
DataSourceWithSyncResponse,
+ RunControlAction,
+ RunControlResponse,
+ MutationLogEntryPreviewPageResponse,
SyncRunLogsResponse,
SyncRunResponse,
+ TriggerSyncRequest,
UpdateDataSourceRequest,
)
from shared_kernel.datasource_types import DataSourceAdapterType
@@ -29,6 +48,178 @@
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,
+ summary="Check remote branch tip and unpulled commits 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:
+ """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,
+ 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,
+ )
+ 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,
+ 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,
@@ -80,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.
@@ -89,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
@@ -102,7 +295,23 @@ 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,
+ 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)
+ 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(
@@ -192,6 +401,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.
@@ -202,6 +412,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
@@ -211,10 +422,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)
@@ -290,6 +503,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,
@@ -503,3 +763,82 @@ 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.
+
+ Entry previews are derived from recorded per-run operation counts,
+ giving users line-by-line visibility beyond aggregate totals.
+ """
+ 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,
+ )
+
+ 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=[
+ {
+ "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=total > 0,
+ )
+
+ 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/management/presentation/knowledge_graphs/models.py b/src/api/management/presentation/knowledge_graphs/models.py
index 4594c6427..f79dc2c76 100644
--- a/src/api/management/presentation/knowledge_graphs/models.py
+++ b/src/api/management/presentation/knowledge_graphs/models.py
@@ -9,8 +9,14 @@
from management.domain.aggregates import KnowledgeGraph
from management.domain.value_objects import (
EdgeTypeDefinition,
+ KnowledgeGraphMaintenanceRunRecord,
+ KnowledgeGraphMaintenanceSchedule,
+ KnowledgeGraphWorkspaceStatus,
NodeTypeDefinition,
OntologyConfig,
+ WorkspaceReadinessStatus,
+ WorkspaceSessionPointers,
+ WorkspaceMode,
)
@@ -71,6 +77,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,11 +100,144 @@ 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,
)
+class WorkspaceReadinessResponse(BaseModel):
+ """Workspace readiness flags for bootstrap transition."""
+
+ 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":
+ 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,
+ prepopulated_types_without_instances=list(
+ readiness.prepopulated_types_without_instances
+ ),
+ blocking_reasons=list(readiness.blocking_reasons),
+ )
+
+
+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
+ ),
+ )
+
+
+class MaintenanceScheduleUpsertRequest(BaseModel):
+ """Request body for KG maintenance schedule upsert."""
+
+ enabled: bool = Field(
+ default=True,
+ description="Whether scheduled maintenance is enabled for this KG",
+ )
+ cron_expression: str = Field(
+ default="0 2 * * *",
+ description="Cron expression interpreted in timezone_name",
+ )
+ timezone_name: str = Field(
+ default="UTC",
+ description="IANA timezone identifier used for schedule evaluation",
+ )
+
+
+class MaintenanceScheduleResponse(BaseModel):
+ """Response model for KG maintenance schedule configuration."""
+
+ enabled: bool
+ cron_expression: str
+ timezone_name: str
+ next_run_at: datetime | None
+
+ @classmethod
+ def from_domain(
+ cls, schedule: KnowledgeGraphMaintenanceSchedule
+ ) -> "MaintenanceScheduleResponse":
+ return cls(
+ enabled=schedule.enabled,
+ cron_expression=schedule.cron_expression,
+ timezone_name=schedule.timezone_name,
+ next_run_at=schedule.next_run_at,
+ )
+
+
+class MaintenanceRunResponse(BaseModel):
+ """Response model for an individual KG maintenance run outcome."""
+
+ run_id: str
+ triggered_at: datetime
+ outcome: str
+ message: str | None
+ target_data_source_ids: list[str]
+
+ @classmethod
+ def from_domain(
+ cls, run: KnowledgeGraphMaintenanceRunRecord
+ ) -> "MaintenanceRunResponse":
+ return cls(
+ run_id=run.run_id,
+ triggered_at=run.triggered_at,
+ outcome=run.outcome.value,
+ message=run.message,
+ target_data_source_ids=list(run.target_data_source_ids),
+ )
+
+
+class MaintenanceRunListResponse(BaseModel):
+ """Response model for KG maintenance run history."""
+
+ runs: list[MaintenanceRunResponse]
+ count: int
+
+
# ---------------------------------------------------------------------------
# Ontology models
# ---------------------------------------------------------------------------
@@ -113,6 +256,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."""
@@ -121,6 +273,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
@@ -131,6 +285,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/management/presentation/knowledge_graphs/routes.py b/src/api/management/presentation/knowledge_graphs/routes.py
index 3f9ca0524..ba24b7e0e 100644
--- a/src/api/management/presentation/knowledge_graphs/routes.py
+++ b/src/api/management/presentation/knowledge_graphs/routes.py
@@ -21,6 +21,11 @@
CreateKnowledgeGraphRequest,
KnowledgeGraphListResponse,
KnowledgeGraphResponse,
+ KnowledgeGraphWorkspaceStatusResponse,
+ MaintenanceRunListResponse,
+ MaintenanceRunResponse,
+ MaintenanceScheduleResponse,
+ MaintenanceScheduleUpsertRequest,
OntologyConfigRequest,
OntologyConfigResponse,
UpdateKnowledgeGraphRequest,
@@ -30,6 +35,160 @@
router = APIRouter(tags=["knowledge-graphs"])
+@router.get(
+ "/knowledge-graphs/{kg_id}/maintenance-schedule",
+ response_model=MaintenanceScheduleResponse,
+ summary="Get KG maintenance schedule configuration",
+)
+async def get_knowledge_graph_maintenance_schedule(
+ kg_id: str,
+ current_user: Annotated[CurrentUser, Depends(get_current_user)],
+ service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)],
+) -> MaintenanceScheduleResponse:
+ """Get knowledge-graph scoped maintenance schedule settings."""
+ try:
+ schedule = await service.get_maintenance_schedule(
+ user_id=current_user.user_id.value,
+ kg_id=kg_id,
+ )
+ return MaintenanceScheduleResponse.from_domain(schedule)
+ 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 load maintenance schedule",
+ )
+
+
+@router.put(
+ "/knowledge-graphs/{kg_id}/maintenance-schedule",
+ response_model=MaintenanceScheduleResponse,
+ summary="Create or update KG maintenance schedule configuration",
+)
+async def upsert_knowledge_graph_maintenance_schedule(
+ kg_id: str,
+ request: MaintenanceScheduleUpsertRequest,
+ current_user: Annotated[CurrentUser, Depends(get_current_user)],
+ service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)],
+) -> MaintenanceScheduleResponse:
+ """Upsert knowledge-graph scoped maintenance schedule settings."""
+ try:
+ schedule = await service.upsert_maintenance_schedule(
+ user_id=current_user.user_id.value,
+ kg_id=kg_id,
+ cron_expression=request.cron_expression,
+ timezone_name=request.timezone_name,
+ enabled=request.enabled,
+ )
+ return MaintenanceScheduleResponse.from_domain(schedule)
+ 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_400_BAD_REQUEST,
+ detail=str(e),
+ )
+ except Exception:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to save maintenance schedule",
+ )
+
+
+@router.get(
+ "/knowledge-graphs/{kg_id}/maintenance-runs",
+ response_model=MaintenanceRunListResponse,
+ summary="List KG maintenance run outcomes",
+)
+async def list_knowledge_graph_maintenance_runs(
+ kg_id: str,
+ current_user: Annotated[CurrentUser, Depends(get_current_user)],
+ service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)],
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
+) -> MaintenanceRunListResponse:
+ """List persisted maintenance orchestration outcomes for a KG."""
+ try:
+ runs = await service.list_maintenance_runs(
+ user_id=current_user.user_id.value,
+ kg_id=kg_id,
+ limit=limit,
+ )
+ items = [MaintenanceRunResponse.from_domain(run) for run in runs]
+ return MaintenanceRunListResponse(runs=items, count=len(items))
+ 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 list maintenance runs",
+ )
+
+
+@router.post(
+ "/knowledge-graphs/{kg_id}/maintenance-runs/trigger",
+ response_model=MaintenanceRunResponse,
+ status_code=status.HTTP_201_CREATED,
+ summary="Trigger KG maintenance orchestration",
+)
+async def trigger_knowledge_graph_maintenance_run(
+ kg_id: str,
+ current_user: Annotated[CurrentUser, Depends(get_current_user)],
+ service: Annotated[KnowledgeGraphService, Depends(get_knowledge_graph_service)],
+) -> MaintenanceRunResponse:
+ """Trigger a maintenance run across all data sources in a knowledge graph."""
+ try:
+ run = await service.trigger_maintenance_run(
+ user_id=current_user.user_id.value,
+ kg_id=kg_id,
+ )
+ return MaintenanceRunResponse.from_domain(run)
+ 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_500_INTERNAL_SERVER_ERROR,
+ detail=str(e),
+ )
+ except Exception:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to trigger maintenance run",
+ )
+
+
@router.get(
"/knowledge-graphs",
status_code=status.HTTP_200_OK,
@@ -156,6 +315,116 @@ 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(
+ "/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/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..865ae7d15
--- /dev/null
+++ b/src/api/shared_kernel/container_runtime/cli_runtime.py
@@ -0,0 +1,110 @@
+"""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}"])
+ for bind in spec.binds:
+ 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)
+
+ 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 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,
+ 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..97a464806
--- /dev/null
+++ b/src/api/shared_kernel/container_runtime/ports.py
@@ -0,0 +1,58 @@
+"""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
+ binds: tuple[str, ...] = field(default_factory=tuple)
+ network: str | None = None
+ detach: bool = True
+ remove_on_exit: bool = False
+ user: str | None = None
+
+
+@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."""
+ ...
+
+ 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/shared_kernel/job_package/archive_availability.py b/src/api/shared_kernel/job_package/archive_availability.py
new file mode 100644
index 000000000..3bc7fd849
--- /dev/null
+++ b/src/api/shared_kernel/job_package/archive_availability.py
@@ -0,0 +1,30 @@
+"""Helpers for checking JobPackage archive presence on disk."""
+
+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()
+
+
+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/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/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/__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..02f55197c
--- /dev/null
+++ b/src/api/tests/integration/extraction/conftest.py
@@ -0,0 +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/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/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/integration/management/conftest.py b/src/api/tests/integration/management/conftest.py
index 8167f93bf..3d40e75f3 100644
--- a/src/api/tests/integration/management/conftest.py
+++ b/src/api/tests/integration/management/conftest.py
@@ -105,7 +105,11 @@ 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/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/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/integration/management/test_knowledge_graph_repository.py b/src/api/tests/integration/management/test_knowledge_graph_repository.py
index 66cac6197..262130e33 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,33 @@ 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
+ assert retrieved.active_extraction_operations_session_id is not None
+
class TestKnowledgeGraphUpdate:
"""Tests for updating knowledge graphs."""
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..dca78f60d
--- /dev/null
+++ b/src/api/tests/integration/management/test_workspace_extraction_mutation_flow.py
@@ -0,0 +1,183 @@
+"""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"
+ )
+ # 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()
+
+ 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,
+ )
+ 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,
+ )
+
+ 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}",
+ "manage",
+ 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/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,
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..444ab8c18
--- /dev/null
+++ b/src/api/tests/unit/extraction/application/test_agent_session_service.py
@@ -0,0 +1,253 @@
+"""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 BootstrapIntakePath, 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)
+
+
+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):
+ 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)
+
+ 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)]
+
+ 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/application/test_chat_turn_service.py b/src/api/tests/unit/extraction/application/test_chat_turn_service.py
new file mode 100644
index 000000000..b579281c1
--- /dev/null
+++ b/src/api/tests/unit/extraction/application/test_chat_turn_service.py
@@ -0,0 +1,288 @@
+"""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.sticky_session_runtime_service import StickySessionRuntimeService
+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"},
+ },
+ )()
+
+
+class _StaticBootstrapBuilder:
+ async def build(self, **kwargs):
+ return None
+
+
+class _InstantHealthChecker:
+ 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(
+ *,
+ readiness: IngestionReadinessSnapshot,
+) -> tuple[ExtractionChatTurnService, _InMemoryAgentSessionRepository]:
+ repo = _InMemoryAgentSessionRepository()
+ sticky = InMemoryStickySessionRuntimeManager()
+ session_service = ExtractionAgentSessionService(repository=repo)
+ runtime_service = StickySessionRuntimeService(
+ session_service=session_service,
+ skill_resolution_service=_StaticSkillResolutionService(),
+ ingestion_readiness_reader=_StaticIngestionReadinessReader(readiness),
+ 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=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
+ 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="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:
+ service, repo = _build_chat_turn_service(readiness=IngestionReadinessSnapshot(2, 0))
+
+ 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,
+ 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"
+
+
+@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
+
+
+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_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
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/application/test_skill_resolution_service.py b/src/api/tests/unit/extraction/application/test_skill_resolution_service.py
new file mode 100644
index 000000000..fa5167b54
--- /dev/null
+++ b/src/api/tests/unit/extraction/application/test_skill_resolution_service.py
@@ -0,0 +1,104 @@
+"""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.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(
+ override_repository=_InMemorySkillOverrideRepository()
+ )
+
+ resolved = await service.resolve_for_session(
+ knowledge_graph_id="kg-1",
+ mode=ExtractionSessionMode.EXTRACTION_OPERATIONS,
+ )
+
+ 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(
+ 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.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(
+ 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.skills.keys())[-2:] == ["a_first", "z_last"]
+
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
new file mode 100644
index 000000000..f16bc3e61
--- /dev/null
+++ b/src/api/tests/unit/extraction/application/test_sticky_session_runtime_service.py
@@ -0,0 +1,355 @@
+"""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 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):
+ 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"
+
+
+@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,
+ )
+ ]
+
+ assert any(event.get("type") == "ready" for event in events)
+ assert bootstrap.calls
+
+
+@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=_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,
+ )
+ session.runtime_context["workspace_materialization"] = {"job_package_ids": ["pkg-1"]}
+ 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 events == []
+ assert session.runtime_context["sticky_runtime"]["phase"] == "ready"
+ 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_container_workload_runtime.py b/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py
new file mode 100644
index 000000000..16c761822
--- /dev/null
+++ b/src/api/tests/unit/extraction/infrastructure/test_container_workload_runtime.py
@@ -0,0 +1,207 @@
+"""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.container_id_for_name.return_value = None
+ 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_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"),
+ ]
+ 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.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="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_extraction_event_handler.py b/src/api/tests/unit/extraction/infrastructure/test_extraction_event_handler.py
index 38738b321..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,19 @@
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
class _FakeOutboxRepository:
@@ -65,6 +71,8 @@ async def run(
data_source_id: str,
knowledge_graph_id: str,
job_package_id: str,
+ runtime_context: ExtractionRuntimeContext,
+ workload_credentials: ScopedWorkloadCredentials | None = None,
) -> str:
self.calls.append(
{
@@ -72,6 +80,8 @@ 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,
+ "workload_credentials": workload_credentials,
}
)
if self._fail:
@@ -79,6 +89,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 +125,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 +179,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 +237,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 +258,7 @@ async def test_extraction_failed_aggregate_type(
handler = ExtractionEventHandler(
extraction_service=failing_service,
outbox=outbox,
+ runtime_context_builder=_FakeRuntimeContextBuilder(),
)
await handler.handle(
"JobPackageProduced",
@@ -239,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)."""
@@ -285,6 +425,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_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_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()
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..75a0c45fb
--- /dev/null
+++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_container_bootstrap.py
@@ -0,0 +1,63 @@
+"""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.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=(),
+ 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")
+ 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.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_sticky_session_workdir_materializer.py b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py
new file mode 100644
index 000000000..f9332d126
--- /dev/null
+++ b/src/api/tests/unit/extraction/infrastructure/test_sticky_session_workdir_materializer.py
@@ -0,0 +1,144 @@
+"""Unit tests for sticky session workdir materialization."""
+
+from __future__ import annotations
+
+import json
+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"
+
+
+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())
+
+
+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)
+ 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()
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/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/extraction/infrastructure/test_workload_runtime.py b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py
new file mode 100644
index 000000000..7e2b4d0d0
--- /dev/null
+++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime.py
@@ -0,0 +1,171 @@
+"""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,
+ ScopedWorkloadCredentials,
+)
+
+
+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_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()
+ 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
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..c6f3afa61
--- /dev/null
+++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_factory.py
@@ -0,0 +1,51 @@
+"""Unit tests for extraction workload runtime factory."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+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)
+
+ 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
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..f03834f1e
--- /dev/null
+++ b/src/api/tests/unit/extraction/infrastructure/test_workload_runtime_settings.py
@@ -0,0 +1,23 @@
+"""Unit tests for extraction workload runtime settings."""
+
+from __future__ import annotations
+
+from extraction.infrastructure.workload_runtime_settings import (
+ ExtractionWorkloadRuntimeSettings,
+)
+
+
+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",
+ worker_command="sleep 120",
+ )
+
+ assert settings.sticky_command == ("sleep", "3600")
+ assert settings.worker_command == ("sleep", "120")
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..5b6d479a6
--- /dev/null
+++ b/src/api/tests/unit/extraction/presentation/test_routes.py
@@ -0,0 +1,232 @@
+"""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 extraction.domain.value_objects import BootstrapIntakePath
+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,
+ 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
+
+ app = FastAPI()
+ 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",
+ 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"]
+
+ 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"
+
+ 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/api/tests/unit/extraction/test_architecture.py b/src/api/tests/unit/extraction/test_architecture.py
new file mode 100644
index 000000000..6a58ac544
--- /dev/null
+++ b/src/api/tests/unit/extraction/test_architecture.py
@@ -0,0 +1,206 @@
+"""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_inner_no_iam")
+ .match(
+ "extraction.domain*",
+ "extraction.ports*",
+ "extraction.application*",
+ "extraction.infrastructure*",
+ )
+ .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")
+ )
+
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,
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/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/application/test_ingestion_service.py b/src/api/tests/unit/ingestion/application/test_ingestion_service.py
index 5329e0e26..17311b46f 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,11 +40,12 @@ 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},
new_checkpoint=checkpoint,
+ branch_file_count=1,
)
@@ -57,6 +59,9 @@ def __init__(
) -> None:
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(
self,
@@ -65,6 +70,9 @@ async def extract(
checkpoint: AdapterCheckpoint | None,
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")
if self._result is not None:
@@ -91,7 +99,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",
@@ -100,7 +108,11 @@ 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.branch_file_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."""
@@ -113,7 +125,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",
@@ -122,7 +134,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):
@@ -169,7 +181,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",
@@ -177,4 +189,51 @@ 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."""
+ 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"}
+
+ 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/adapters/test_github_adapter.py b/src/api/tests/unit/ingestion/infrastructure/adapters/test_github_adapter.py
index 08ec50525..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
@@ -79,6 +80,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 +188,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 +403,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 +457,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 +495,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 +536,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 +573,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 +778,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(
[
@@ -806,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
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..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
@@ -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,
)
@@ -67,18 +68,29 @@ async def run(
connection_config: dict[str, str],
credentials_path: str | None,
tenant_id: str | None = None,
- ) -> JobPackageId:
+ credentials: dict[str, str] | None = None,
+ baseline_commit: str | None = None,
+ pipeline_mode: str = "full",
+ ) -> IngestionRunResult:
self.calls.append(
{
"sync_run_id": sync_run_id,
"data_source_id": data_source_id,
"knowledge_graph_id": knowledge_graph_id,
"adapter_type": adapter_type,
+ "credentials": credentials,
+ "baseline_commit": baseline_commit,
+ "pipeline_mode": pipeline_mode,
}
)
if self._fail:
raise RuntimeError(self._error)
- return JobPackageId(value="01HRZZZZZZZZZZZZZZZZZZZZZ0")
+ return IngestionRunResult(
+ job_package_id=JobPackageId(value="01HRZZZZZZZZZZZZZZZZZZZZZ0"),
+ entry_count=42,
+ branch_file_count=99,
+ prepared_commit_sha="abc123def456",
+ )
@pytest.fixture
@@ -150,6 +162,40 @@ 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_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,
@@ -179,6 +225,60 @@ 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
+
+ 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
+ assert event["payload"]["prepared_commit_sha"] == "abc123def456"
+ assert event["payload"]["prepared_file_count"] == 99
+
+ 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:
@@ -204,6 +304,50 @@ 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,
+ 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"
+ )
+
+ 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,
@@ -296,6 +440,9 @@ 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,
+ pipeline_mode: str = "full",
) -> JobPackageId:
raise asyncio.CancelledError()
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..a453fba2a
--- /dev/null
+++ b/src/api/tests/unit/management/application/test_canonical_schema_service.py
@@ -0,0 +1,159 @@
+"""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
+
+ @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_data_source_service.py b/src/api/tests/unit/management/application/test_data_source_service.py
index 960f49cab..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
@@ -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,198 @@ 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."""
+
+ @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",
+ )
+
+ 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_updates_tracked_head_only(
+ self, service, authz, ds_repo, user_id
+ ) -> None:
+ """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()
+
+ updated = await service.refresh_commit_references(
+ user_id=user_id,
+ ds_id=ds.id.value,
+ tracked_branch_head_commit="abc123",
+ )
+
+ assert updated.tracked_branch_head_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(
+ 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",
+ )
+
+ 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/application/test_knowledge_graph_service.py b/src/api/tests/unit/management/application/test_knowledge_graph_service.py
index 423e2e510..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
@@ -19,11 +19,20 @@
KnowledgeGraphService,
)
from management.domain.aggregates import DataSource, KnowledgeGraph
+from management.domain.entities.data_source_sync_run import DataSourceSyncRun
from management.domain.value_objects import (
DataSourceId,
+ EdgeTypeDefinition,
+ KnowledgeGraphMaintenanceRunOutcome,
+ KnowledgeGraphMaintenanceSchedule,
+ KnowledgeGraphMaintenanceRunRecord,
+ KnowledgeGraphWorkspaceStatus,
KnowledgeGraphId,
+ NodeTypeDefinition,
+ OntologyConfig,
Schedule,
ScheduleType,
+ WorkspaceMode,
)
from shared_kernel.datasource_types import DataSourceAdapterType
from management.ports.exceptions import (
@@ -33,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,
@@ -106,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,
@@ -116,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,
)
@@ -147,6 +173,18 @@ def _make_kg(
return kg
+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)
+ canonical_schema_repo.seed(kg.id.value, config)
+
+
def _make_ds(
ds_id: str = "ds-001",
kg_id: str = "kg-001",
@@ -410,6 +448,206 @@ 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, canonical_schema_repo, user_id
+ ):
+ """Should project mode/readiness flags and default null session pointers."""
+ kg = _make_kg()
+ ontology_config = OntologyConfig(
+ node_types=(NodeTypeDefinition(label="Repository"),),
+ edge_types=(
+ EdgeTypeDefinition(
+ label="CONTAINS",
+ source_labels=("Repository",),
+ target_labels=("Repository",),
+ ),
+ ),
+ )
+ 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)
+
+ 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.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
+ 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 "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, canonical_schema_repo, user_id
+ ):
+ """Should block transition when prepopulated type has zero instances."""
+ kg = _make_kg()
+ 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",),
+ ),
+ ),
+ )
+ 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)
+
+ 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
+
+
+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, canonical_schema_repo, user_id
+ ):
+ kg = _make_kg()
+ ontology_config = OntologyConfig(
+ node_types=(NodeTypeDefinition(label="Repository"),),
+ edge_types=(
+ EdgeTypeDefinition(
+ label="CONTAINS",
+ source_labels=("Repository",),
+ target_labels=("Repository",),
+ ),
+ ),
+ )
+ 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):
+ 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, canonical_schema_repo, user_id
+ ):
+ kg = _make_kg()
+ ontology_config = OntologyConfig(
+ node_types=(NodeTypeDefinition(label="Repository"),),
+ edge_types=(
+ EdgeTypeDefinition(
+ label="CONTAINS",
+ source_labels=("Repository",),
+ target_labels=("Repository",),
+ ),
+ ),
+ )
+ 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(
+ 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 ----
@@ -1078,6 +1316,140 @@ async def test_returns_only_kgs_in_workspace_with_edit_permission(
assert len(result) == 1
assert result[0].id.value == kg1.id.value
+
+class _InMemorySyncRunRepository:
+ """Minimal in-memory sync-run repository for KG maintenance tests."""
+
+ def __init__(self) -> None:
+ self.saved: list[DataSourceSyncRun] = []
+
+ async def save(self, sync_run: DataSourceSyncRun) -> None:
+ self.saved.append(sync_run)
+
+ async def get_by_id(self, sync_run_id: str) -> DataSourceSyncRun | None:
+ for run in self.saved:
+ if run.id == sync_run_id:
+ return run
+ return None
+
+ async def find_by_data_source(self, data_source_id: str) -> list[DataSourceSyncRun]:
+ return [run for run in self.saved if run.data_source_id == data_source_id]
+
+ async def get_latest_for_data_source(
+ self, data_source_id: str
+ ) -> DataSourceSyncRun | None:
+ matches = [run for run in self.saved if run.data_source_id == data_source_id]
+ return max(matches, key=lambda run: run.created_at) if matches else None
+
+
+class TestKnowledgeGraphMaintenanceScheduling:
+ """Tests for KG-scoped maintenance schedule and run history APIs."""
+
+ @pytest.mark.asyncio
+ async def test_upsert_maintenance_schedule_persists_timezone_and_next_run(
+ self, mock_session, kg_repo, ds_repo, secret_store, authz, probe, tenant_id, user_id
+ ):
+ """Upserting schedule stores config and computes a next_run_at timestamp."""
+ kg = _make_kg(kg_id="kg-maint-001", tenant_id=tenant_id)
+ kg_repo.seed(kg)
+ await _grant_kg_manage(authz, kg.id.value, user_id)
+ sync_run_repo = _InMemorySyncRunRepository()
+ svc = KnowledgeGraphService(
+ session=mock_session,
+ knowledge_graph_repository=kg_repo,
+ data_source_repository=ds_repo,
+ secret_store=secret_store,
+ authz=authz,
+ scope_to_tenant=tenant_id,
+ probe=probe,
+ sync_run_repository=sync_run_repo,
+ )
+
+ schedule = await svc.upsert_maintenance_schedule(
+ user_id=user_id,
+ kg_id=kg.id.value,
+ cron_expression="0 9 * * *",
+ timezone_name="America/New_York",
+ enabled=True,
+ )
+
+ assert isinstance(schedule, KnowledgeGraphMaintenanceSchedule)
+ assert schedule.cron_expression == "0 9 * * *"
+ assert schedule.timezone_name == "America/New_York"
+ assert schedule.enabled is True
+ assert schedule.next_run_at is not None
+
+ @pytest.mark.asyncio
+ async def test_trigger_maintenance_run_records_no_changes_outcome(
+ self, mock_session, kg_repo, ds_repo, secret_store, authz, probe, tenant_id, user_id
+ ):
+ """When no DS has commit deltas, trigger records NO_CHANGES."""
+ kg = _make_kg(kg_id="kg-maint-002", tenant_id=tenant_id)
+ kg_repo.seed(kg)
+ await _grant_kg_manage(authz, kg.id.value, user_id)
+
+ ds_no_change = _make_ds(ds_id="ds-no-change", kg_id=kg.id.value, tenant_id=tenant_id)
+ ds_no_change.last_extraction_baseline_commit = "abc123"
+ ds_no_change.tracked_branch_head_commit = "abc123"
+ ds_repo.seed(ds_no_change)
+
+ sync_run_repo = _InMemorySyncRunRepository()
+ svc = KnowledgeGraphService(
+ session=mock_session,
+ knowledge_graph_repository=kg_repo,
+ data_source_repository=ds_repo,
+ secret_store=secret_store,
+ authz=authz,
+ scope_to_tenant=tenant_id,
+ probe=probe,
+ sync_run_repository=sync_run_repo,
+ )
+
+ run = await svc.trigger_maintenance_run(
+ user_id=user_id,
+ kg_id=kg.id.value,
+ )
+
+ assert isinstance(run, KnowledgeGraphMaintenanceRunRecord)
+ assert run.outcome == KnowledgeGraphMaintenanceRunOutcome.NO_CHANGES
+ assert run.target_data_source_ids == ("ds-no-change",)
+ assert len(sync_run_repo.saved) == 0
+
+ @pytest.mark.asyncio
+ async def test_trigger_maintenance_run_records_started_and_creates_sync_runs(
+ self, mock_session, kg_repo, ds_repo, secret_store, authz, probe, tenant_id, user_id
+ ):
+ """When DS commit deltas exist, trigger records STARTED and enqueues sync runs."""
+ kg = _make_kg(kg_id="kg-maint-003", tenant_id=tenant_id)
+ kg_repo.seed(kg)
+ await _grant_kg_manage(authz, kg.id.value, user_id)
+
+ ds_changed = _make_ds(ds_id="ds-changed", kg_id=kg.id.value, tenant_id=tenant_id)
+ ds_changed.last_extraction_baseline_commit = "abc123"
+ ds_changed.tracked_branch_head_commit = "def456"
+ ds_repo.seed(ds_changed)
+
+ sync_run_repo = _InMemorySyncRunRepository()
+ svc = KnowledgeGraphService(
+ session=mock_session,
+ knowledge_graph_repository=kg_repo,
+ data_source_repository=ds_repo,
+ secret_store=secret_store,
+ authz=authz,
+ scope_to_tenant=tenant_id,
+ probe=probe,
+ sync_run_repository=sync_run_repo,
+ )
+
+ run = await svc.trigger_maintenance_run(
+ user_id=user_id,
+ kg_id=kg.id.value,
+ )
+
+ assert run.outcome == KnowledgeGraphMaintenanceRunOutcome.STARTED
+ assert run.target_data_source_ids == ("ds-changed",)
+ assert len(sync_run_repo.saved) == 1
+
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_kgs_in_workspace(
self, service, authz, user_id, workspace_id
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_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/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/infrastructure/test_sync_lifecycle_handler.py b/src/api/tests/unit/management/infrastructure/test_sync_lifecycle_handler.py
index 974f60b26..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
@@ -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,58 @@ 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
+
+ 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
+ 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()
+
+
@pytest.mark.asyncio
class TestJobPackageProducedTransition:
"""JobPackageProduced β status = ai_extracting."""
@@ -190,6 +243,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 +346,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,
@@ -285,6 +427,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,
)
@@ -302,6 +446,49 @@ 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,
+ 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
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..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
@@ -18,6 +18,8 @@
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,
Ontology,
@@ -43,6 +45,18 @@ 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_commit_reference_service() -> AsyncMock:
+ """Mock GitCommitReferenceService for commit-ref route testing."""
+ return AsyncMock()
+
+
@pytest.fixture
def mock_current_user() -> CurrentUser:
"""Mock CurrentUser for authentication."""
@@ -69,6 +83,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",
)
@@ -87,25 +104,51 @@ 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,
mock_sync_run_repo: AsyncMock,
+ 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,
+ get_git_diff_summary_service,
get_sync_run_repository,
)
from management.presentation import router
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] = (
+ 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.dependency_overrides[get_write_session] = _override_write_session
app.include_router(router)
@@ -134,6 +177,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,
@@ -350,6 +402,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(
@@ -414,6 +488,74 @@ 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_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["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"] == {
+ "create_node": 8,
+ "create_edge": 13,
+ "update_node": 2,
+ }
+
def test_list_sync_runs_returns_404_when_ds_not_found(
self,
test_client: TestClient,
@@ -431,6 +573,204 @@ 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_previews_from_operation_counts(
+ 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["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,
+ "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}."""
+
+ 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.
@@ -716,6 +1056,150 @@ 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 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,
+ mock_current_user: CurrentUser,
+ sample_data_source: DataSource,
+ ) -> None:
+ """Refresh endpoint should return updated commit references."""
+ refreshed = sample_data_source
+ refreshed.tracked_branch_head_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
+
+ 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["tracked_branch_head_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,
+ 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/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py b/src/api/tests/unit/management/presentation/test_knowledge_graphs_routes.py
index 4c5e6c009..cc74f9ed6 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,16 @@
KnowledgeGraphService,
)
from management.domain.aggregates import KnowledgeGraph
-from management.domain.value_objects import KnowledgeGraphId
+from management.domain.value_objects import (
+ KnowledgeGraphMaintenanceRunOutcome,
+ KnowledgeGraphMaintenanceRunRecord,
+ KnowledgeGraphMaintenanceSchedule,
+ KnowledgeGraphId,
+ KnowledgeGraphWorkspaceStatus,
+ WorkspaceMode,
+ WorkspaceReadinessStatus,
+ WorkspaceSessionPointers,
+)
from management.ports.exceptions import (
DuplicateKnowledgeGraphNameError,
KnowledgeGraphNotFoundError,
@@ -100,6 +109,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 +267,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,
@@ -289,6 +303,289 @@ 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["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
+
+ 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 TestKnowledgeGraphMaintenanceRoutes:
+ """Tests for KG maintenance schedule and run history routes."""
+
+ def test_get_maintenance_schedule_returns_200(
+ self,
+ test_client: TestClient,
+ mock_kg_service: AsyncMock,
+ sample_knowledge_graph: KnowledgeGraph,
+ mock_current_user: CurrentUser,
+ ) -> None:
+ mock_kg_service.get_maintenance_schedule.return_value = (
+ KnowledgeGraphMaintenanceSchedule(
+ enabled=True,
+ cron_expression="0 9 * * *",
+ timezone_name="UTC",
+ next_run_at=datetime.now(UTC),
+ )
+ )
+
+ response = test_client.get(
+ f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/maintenance-schedule"
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ payload = response.json()
+ assert payload["enabled"] is True
+ assert payload["cron_expression"] == "0 9 * * *"
+ mock_kg_service.get_maintenance_schedule.assert_called_once_with(
+ user_id=mock_current_user.user_id.value,
+ kg_id=sample_knowledge_graph.id.value,
+ )
+
+ def test_put_maintenance_schedule_calls_service(
+ self,
+ test_client: TestClient,
+ mock_kg_service: AsyncMock,
+ sample_knowledge_graph: KnowledgeGraph,
+ mock_current_user: CurrentUser,
+ ) -> None:
+ mock_kg_service.upsert_maintenance_schedule.return_value = (
+ KnowledgeGraphMaintenanceSchedule(
+ enabled=True,
+ cron_expression="30 8 * * *",
+ timezone_name="America/New_York",
+ next_run_at=datetime.now(UTC),
+ )
+ )
+
+ response = test_client.put(
+ f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/maintenance-schedule",
+ json={
+ "enabled": True,
+ "cron_expression": "30 8 * * *",
+ "timezone_name": "America/New_York",
+ },
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ mock_kg_service.upsert_maintenance_schedule.assert_called_once_with(
+ user_id=mock_current_user.user_id.value,
+ kg_id=sample_knowledge_graph.id.value,
+ cron_expression="30 8 * * *",
+ timezone_name="America/New_York",
+ enabled=True,
+ )
+
+ def test_list_maintenance_runs_returns_200(
+ self,
+ test_client: TestClient,
+ mock_kg_service: AsyncMock,
+ sample_knowledge_graph: KnowledgeGraph,
+ mock_current_user: CurrentUser,
+ ) -> None:
+ mock_kg_service.list_maintenance_runs.return_value = [
+ KnowledgeGraphMaintenanceRunRecord(
+ run_id="01JTESTRUN1234567890ABCDE",
+ triggered_at=datetime.now(UTC),
+ outcome=KnowledgeGraphMaintenanceRunOutcome.NO_CHANGES,
+ message="No commit delta detected",
+ target_data_source_ids=("ds-1",),
+ )
+ ]
+
+ response = test_client.get(
+ f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/maintenance-runs"
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ payload = response.json()
+ assert len(payload["runs"]) == 1
+ assert payload["runs"][0]["outcome"] == "no-changes"
+ mock_kg_service.list_maintenance_runs.assert_called_once_with(
+ user_id=mock_current_user.user_id.value,
+ kg_id=sample_knowledge_graph.id.value,
+ limit=20,
+ )
+
+ def test_trigger_maintenance_run_returns_201(
+ self,
+ test_client: TestClient,
+ mock_kg_service: AsyncMock,
+ sample_knowledge_graph: KnowledgeGraph,
+ mock_current_user: CurrentUser,
+ ) -> None:
+ mock_kg_service.trigger_maintenance_run.return_value = (
+ KnowledgeGraphMaintenanceRunRecord(
+ run_id="01JTRIGGER1234567890ABCDE",
+ triggered_at=datetime.now(UTC),
+ outcome=KnowledgeGraphMaintenanceRunOutcome.STARTED,
+ message="Scheduled maintenance launched",
+ target_data_source_ids=("ds-1", "ds-2"),
+ )
+ )
+
+ response = test_client.post(
+ f"/management/knowledge-graphs/{sample_knowledge_graph.id.value}/maintenance-runs/trigger"
+ )
+
+ assert response.status_code == status.HTTP_201_CREATED
+ payload = response.json()
+ assert payload["outcome"] == "started"
+ mock_kg_service.trigger_maintenance_run.assert_called_once_with(
+ user_id=mock_current_user.user_id.value,
+ kg_id=sample_knowledge_graph.id.value,
+ )
+
+
+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_data_source.py b/src/api/tests/unit/management/test_data_source.py
index aa709e4b9..184b2be45 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(
@@ -419,6 +432,47 @@ 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.clone_head_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
+
+ 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."""
diff --git a/src/api/tests/unit/management/test_knowledge_graph.py b/src/api/tests/unit/management/test_knowledge_graph.py
index 01ae468f3..804c76970 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,41 @@ 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()
+
+ 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."""
+ 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."""
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:
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..c161ad7d1
--- /dev/null
+++ b/src/api/tests/unit/shared_kernel/container_runtime/test_cli_runtime.py
@@ -0,0 +1,108 @@
+"""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_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")
+
+ 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
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..bd4ac7e0c
--- /dev/null
+++ b/src/api/tests/unit/shared_kernel/job_package/test_archive_availability.py
@@ -0,0 +1,57 @@
+"""Unit tests for JobPackage archive availability helpers."""
+
+from __future__ import annotations
+
+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 (
+ AdapterCheckpoint,
+ ChangeOperation,
+ ChangesetEntry,
+ ContentRef,
+ JobPackageId,
+ SyncMode,
+)
+
+
+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"
+ 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
new file mode 100644
index 000000000..79a6715ea
--- /dev/null
+++ b/src/api/tests/unit/test_sessioned_ingestion_handler.py
@@ -0,0 +1,315 @@
+"""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 "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"
+
+
+@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
+ assert "credentials" not in call_payload
+ assert (
+ ingestion_handler.handle.call_args.kwargs["runtime_credentials"]
+ == {"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]
+ handler._ingest_only_archive_available = AsyncMock(return_value=True) # 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
+
+
+@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/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/extraction/SharedConversationPanel.vue b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue
new file mode 100644
index 000000000..1bd788399
--- /dev/null
+++ b/src/dev-ui/app/components/extraction/SharedConversationPanel.vue
@@ -0,0 +1,410 @@
+
+
+
+
+
+
+
+
+
+
+
{{ title }}
+
+ {{ description }}
+
+
+ Mode:
+ {{ modeLabel }}
+ Β· Session:
+ {{ sessionStatusLabel }}
+
+
+
+
+
+ Resume session
+
+
+
+
+ Clear chat
+
+
+
+
+
+
+
+ {{ forbiddenReason ?? 'You do not have permission to use graph management chat for this knowledge graph.' }}
+
+
+
+
+
+
Loading conversation sessionβ¦
+
+
+
+
+
+
+
+
+
+ {{ messageText(entry) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ runtimeActivityTitle }}
+
+
+
+
+ β
+
+
+ {{ line || 'β' }}
+
+
+
+
+
+
+
+ No messages yet. Send a prompt or use validate/transition actions to drive session activity.
+
+
+
+
+
+
Message to graph management assistant
+
+
+
+
+
+
+ Send
+
+
+
+
+ {{ footerHint }}
+ Β· Enter to send, Shift+Enter for a new line.
+
+
+
+
+
+
+
+
+ Clear conversation?
+
+ This starts a fresh server-side session timeline while keeping the selected graph management mode.
+
+
+
+ Cancel
+
+ Clear chat
+
+
+
+
+
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/data-sources/index.vue b/src/dev-ui/app/pages/data-sources/index.vue
index 573c7b6bf..6aae7709c 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,
@@ -22,19 +20,18 @@ import {
ScrollText,
FileText,
Settings,
+ RefreshCw,
} 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'
@@ -43,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'
@@ -85,6 +89,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
}
@@ -95,15 +101,43 @@ interface DataSourceItem {
knowledge_graph_id: string
last_sync_at: string | null
created_at: string
+ 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 {
+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 {
@@ -165,59 +199,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 = {
- 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([{ id: 'source-1', url: '' }])
+const sourceUrlError = ref('')
+const providerError = ref('')
+const pendingSources = ref([])
+const detectingSourceDetails = ref(false)
const knowledgeGraphs = ref>([])
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([])
-const proposedEdges = ref([])
-
// ββ GitHub ontology proposal βββββββββββββββββββββββββββββββββββββββββββββββ
const GITHUB_PROPOSAL_NODES: Omit[] = [
@@ -290,8 +295,6 @@ const GITHUB_PROPOSAL_EDGES: Omit adapters.find((a) => a.id === selectedAdapterId.value))
-
function toProposedNode(n: typeof GITHUB_PROPOSAL_NODES[0]): ProposedNodeType {
return {
...n,
@@ -314,15 +317,42 @@ function toProposedEdge(e: typeof GITHUB_PROPOSAL_EDGES[0]): ProposedEdgeType {
}
}
-// ββ Infer data source name from repo URL βββββββββββββββββββββββββββββββββββ
+// ββ URL detection & inference βββββββββββββββββββββββββββββββββββββββββββββββ
+
+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)
+}
-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
+const sourceUrlPreviews = computed(() => {
+ const seen = new Set()
+ 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -338,183 +368,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((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 βββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -562,32 +542,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
}
}
@@ -595,6 +606,70 @@ async function approveOntology() {
const dataSources = ref([])
const loadingDataSources = ref(false)
+const scopedKnowledgeGraphId = ref('')
+const manageReturnKgId = ref('')
+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
+ 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
+}
+
+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
@@ -623,6 +698,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 {
@@ -701,8 +783,15 @@ 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=. Reading
// this param here ensures the wizard opens immediately with the right KG chosen.
+ // Manage workspace navigation contract: ?kg_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'
+
+ if (fromManage && preselectedKgId) {
+ scopedKnowledgeGraphId.value = preselectedKgId
+ manageReturnKgId.value = preselectedKgId
+ } else if (preselectedKgId) {
await nextTick()
openWizard(preselectedKgId)
}
@@ -1051,10 +1140,19 @@ async function handleDeleteDs() {
-
-
- Add Data Source
-
+
+
+ Back to workspace overview
+
+
+
+ Add Data Source
+
+
@@ -1067,8 +1165,18 @@ async function handleDeleteDs() {
+
+
+ Data source catalog
+
+ This page is optimized for source onboarding and source-level actions.
+ Graph-wide run telemetry and maintenance controls live in the manage workspace.
+
+
+
+
-
+
@@ -1088,7 +1196,7 @@ async function handleDeleteDs() {
-
+
@@ -1141,6 +1249,96 @@ async function handleDeleteDs() {
+
+
+
Commit Status
+
+
+
Commit during last extraction
+
{{ ds.last_extraction_baseline_commit ?? 'β' }}
+
+
+
Tracked branch head commit
+
{{ ds.tracked_branch_head_commit ?? 'β' }}
+
+
+
+
+
+ {{ refreshingCommitRefs[ds.id] === true ? 'Refreshingβ¦' : 'Refresh commits' }}
+
+
+ {{ adoptingBaselines[ds.id] === true ? 'Updatingβ¦' : 'Adopt tracked head as baseline' }}
+
+
+
+
+
+
+ {{ 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.
+
+
+ {{ isDiffExpanded(ds.id) ? 'Hide changed files' : 'Show changed files' }}
+
+
+
+
+
+ {{ file.path }}
+ {{ file.status }}
+
+
+ Showing first {{ ds.diff_summary.changed_files.length }} files. Refine or page for full list.
+
+
+
+
Sync History
@@ -1204,24 +1402,31 @@ async function handleDeleteDs() {
-
+
-
Select an adapter type
-
Choose the system you want to import data from.
+
Paste your source URLs
+
+ Add one source at a time with "Add another". We auto-detect provider and prepare all supported sources at once.
+
Knowledge Graph *
-
- Select a knowledge graph...
- {{ kg.name }}
-
+
+
+
+
+
+ {{ kg.name }}
+
+
+
Loading knowledge graphs...
@@ -1230,44 +1435,59 @@ async function handleDeleteDs() {
-
-
+ Data source URLs *
+
-
-
-
-
-
-
-
{{ adapter.label }}
-
- Soon
-
-
-
-
{{ adapter.description }}
-
+
+
+
+ Remove
+
+
+
+ Detected:
+
+ {{ providerLabel(detectAdapterFromUrl(row.url)) }}
+
-
+
+
+
+ Add another
+
+
+
{{ sourceUrlError }}
+
+ {{ providerError }}
+
+
+ GitHub is fully supported now. GitLab and Jira are detected and shown as coming soon.
+
-
+
Continue
@@ -1277,76 +1497,75 @@ async function handleDeleteDs() {
-
Configure connection
+
Confirm connection details
- Provide the details to connect your
- {{ selectedAdapter?.label }} repository.
+ Review each detected source, adjust inferred name/branch if needed, then connect them all at once.
-
-
-
-
- Repository URL *
-
-
-
{{ connRepoUrlError }}
-
- The full HTTPS URL of the GitHub repository to index.
-
-
-
-
-
- Access Token *
-
-
-
-
-
-
-
+
+
+
+
{{ entry.url }}
+
{{ providerLabel(entry.detectedAdapterId) }}
-
{{ connTokenError }}
-
- A GitHub personal access token with read:repo scope.
-
+
+
+
Data Source Name *
+
+
{{ entry.nameError }}
+
+
+
Tracked Branch *
+
+
{{ entry.branchError }}
+
Default branch is auto-detected when available.
+
+
+
{{ entry.urlError }}
+
-
-
- Data Source Name *
-
+
+
+ Access Token (optional)
+
+
-
{{ connNameError }}
-
- Auto-inferred from the repository URL. You can rename it here.
-
+
+
+
+
+
{{ connTokenError }}
+
+ A GitHub personal access token with read:repo scope.
+
@@ -1363,306 +1582,13 @@ async function handleDeleteDs() {
Back
-
- Continue
-
+
+
+ Add to project
-
-
-
-
Describe your intent
-
- Tell the AI agent what problems or questions you want to solve with this data.
- This shapes the proposed knowledge graph ontology.
-
-
-
-
-
What do you want to learn from this data?
-
-
{{ intentError }}
-
- The more specific you are, the better the proposed ontology will match your needs.
-
-
-
-
-
-
- Back
-
-
- Analyse & Propose Ontology
-
-
-
-
-
-
-
-
-
-
-
-
Analysing your data sourceβ¦
-
- Scanning repository structure and applying your intent to propose an ontology.
-
-
-
-
-
-
-
-
Review proposed ontology
-
- 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.
-
-
-
-
-
-
-
- Modifying the ontology after the initial extraction is complete will trigger a full
- re-extraction of this data source. Approve carefully.
-
-
-
-
-
-
- Node Types ({{ proposedNodes.length }})
-
-
-
-
-
- Node
-
-
{{ node.label }}
-
{{ node.description }}
-
-
- {{ prop }} *
-
-
- {{ prop }}
-
-
-
-
-
-
-
-
-
-
- Edit type
-
-
-
-
-
-
-
- Remove type
-
-
-
-
-
-
-
-
-
Label
-
-
{{ node.editError }}
-
-
- Description
-
-
-
-
-
-
-
- Cancel
-
-
-
- Save
-
-
-
-
-
-
-
-
- Add Node Type
-
-
-
-
-
-
- Edge Types ({{ proposedEdges.length }})
-
-
-
-
-
- Edge
-
-
{{ edge.label }}
-
{{ edge.description }}
-
- {{ edge.from }} β {{ edge.to }}
-
-
-
- {{ prop }} *
-
-
- {{ prop }}
-
-
-
-
-
-
-
-
-
-
- Edit type
-
-
-
-
-
-
-
- Remove type
-
-
-
-
-
-
-
-
-
Label
-
-
{{ edge.editError }}
-
-
- Description
-
-
-
-
-
-
-
-
- Cancel
-
-
-
- Save
-
-
-
-
-
-
-
-
- Add Edge Type
-
-
-
-
-
-
-
- Back
-
-
-
-
- Approve & Start Extraction
-
-
-
diff --git a/src/dev-ui/app/pages/graph/mutations.vue b/src/dev-ui/app/pages/graph/mutations.vue
index 5280e7b71..5230cf6cf 100644
--- a/src/dev-ui/app/pages/graph/mutations.vue
+++ b/src/dev-ui/app/pages/graph/mutations.vue
@@ -11,13 +11,14 @@ import {
FileCode, Play, Trash2, Upload, Loader2,
FileUp, XCircle, AlertTriangle, Building2,
Plus, GitBranch, RefreshCw, BookOpen,
- BookMarked, FolderTree,
+ BookMarked, FolderTree, Sparkles, MessageSquare,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import {
Select,
SelectContent,
@@ -177,6 +178,18 @@ watch(selectedWorkspaceId, (wsId) => {
else knowledgeGraphs.value = []
})
+watch(selectedKnowledgeGraphId, () => {
+ loadLiveInspector()
+})
+
+watch(() => submission.state.value.status, async (status) => {
+ if (status === 'success') {
+ // Applied changes become normal after refresh per session behavior decision.
+ resetSessionEditHighlights()
+ await loadLiveInspector()
+ }
+})
+
// ββ Worker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const { workerResult, parsing, parseTimeMs, isLargeFile, requestParse } = useMutationWorker()
@@ -302,6 +315,198 @@ const breakdown = computed(() => {
return { DEFINE: 0, CREATE: 0, UPDATE: 0, DELETE: 0, unknown: 0 }
})
+// ββ Manual mutation assistant + live inspector βββββββββββββββββββββββββββββ
+
+interface AssistantMessage {
+ id: string
+ role: 'user' | 'assistant'
+ text: string
+}
+
+interface InspectorEntityRow {
+ id: string
+ type: string
+ label: string
+ properties: Record
+}
+
+interface InspectorRelationshipRow {
+ id: string
+ type: string
+ source: string
+ target: string
+ properties: Record
+}
+
+const inspectorTab = ref<'entities' | 'relationships'>('entities')
+const assistantPrompt = ref('')
+const assistantMessages = ref([
+ {
+ id: 'assistant-intro',
+ role: 'assistant',
+ text: 'Describe a manual graph change and I will draft JSONL mutations you can submit directly.',
+ },
+])
+const assistantBusy = ref(false)
+const inspectorLoading = ref(false)
+const inspectorError = ref(null)
+const inspectorEntities = ref([])
+const inspectorRelationships = ref([])
+const sessionEditedTypes = ref>(new Set())
+const sessionEditedFields = ref>(new Set())
+const sessionEditedEntityIds = ref>(new Set())
+const sessionEditedRelationshipIds = ref>(new Set())
+
+function isEntityEdited(row: InspectorEntityRow): boolean {
+ return (
+ sessionEditedEntityIds.value.has(row.id)
+ || sessionEditedTypes.value.has(row.type)
+ )
+}
+
+function isRelationshipEdited(row: InspectorRelationshipRow): boolean {
+ return (
+ sessionEditedRelationshipIds.value.has(row.id)
+ || sessionEditedTypes.value.has(row.type)
+ )
+}
+
+function isPropertyEdited(key: string): boolean {
+ return sessionEditedFields.value.has(key)
+}
+
+function markEditedFromOperations(operations: ParseResult['operations']): void {
+ const nextTypes = new Set(sessionEditedTypes.value)
+ const nextFields = new Set(sessionEditedFields.value)
+ const nextEntities = new Set(sessionEditedEntityIds.value)
+ const nextRelationships = new Set(sessionEditedRelationshipIds.value)
+
+ for (const op of operations) {
+ if (op.label) nextTypes.add(String(op.label))
+ if (op.id) {
+ if (op.type === 'edge') nextRelationships.add(String(op.id))
+ else nextEntities.add(String(op.id))
+ }
+ const raw = op.raw as Record
+ const setProps = raw.set_properties
+ if (setProps && typeof setProps === 'object' && !Array.isArray(setProps)) {
+ for (const key of Object.keys(setProps as Record)) {
+ nextFields.add(key)
+ }
+ }
+ const removeProps = raw.remove_properties
+ if (Array.isArray(removeProps)) {
+ for (const key of removeProps) {
+ if (typeof key === 'string') nextFields.add(key)
+ }
+ }
+ }
+
+ sessionEditedTypes.value = nextTypes
+ sessionEditedFields.value = nextFields
+ sessionEditedEntityIds.value = nextEntities
+ sessionEditedRelationshipIds.value = nextRelationships
+}
+
+function resetSessionEditHighlights(): void {
+ sessionEditedTypes.value = new Set()
+ sessionEditedFields.value = new Set()
+ sessionEditedEntityIds.value = new Set()
+ sessionEditedRelationshipIds.value = new Set()
+}
+
+async function loadLiveInspector() {
+ if (!selectedKnowledgeGraphId.value) {
+ inspectorEntities.value = []
+ inspectorRelationships.value = []
+ return
+ }
+
+ inspectorLoading.value = true
+ inspectorError.value = null
+ try {
+ const { queryGraph } = useQueryApi()
+ const [entitiesResult, relationshipsResult] = await Promise.all([
+ queryGraph(
+ "MATCH (n) RETURN coalesce(n.id, toString(id(n))) AS id, head(labels(n)) AS type, coalesce(n.name, n.slug, toString(id(n))) AS label, properties(n) AS properties ORDER BY type, label LIMIT 30",
+ 30,
+ 30,
+ selectedKnowledgeGraphId.value,
+ ),
+ queryGraph(
+ "MATCH (a)-[r]->(b) RETURN coalesce(r.id, toString(id(r))) AS id, type(r) AS type, coalesce(a.name, a.slug, toString(id(a))) AS source, coalesce(b.name, b.slug, toString(id(b))) AS target, properties(r) AS properties ORDER BY type LIMIT 30",
+ 30,
+ 30,
+ selectedKnowledgeGraphId.value,
+ ),
+ ])
+
+ inspectorEntities.value = entitiesResult.rows.map((row) => ({
+ id: String(row.id ?? ''),
+ type: String(row.type ?? 'Unknown'),
+ label: String(row.label ?? ''),
+ properties:
+ row.properties && typeof row.properties === 'object' && !Array.isArray(row.properties)
+ ? (row.properties as Record)
+ : {},
+ }))
+ inspectorRelationships.value = relationshipsResult.rows.map((row) => ({
+ id: String(row.id ?? ''),
+ type: String(row.type ?? 'Unknown'),
+ source: String(row.source ?? ''),
+ target: String(row.target ?? ''),
+ properties:
+ row.properties && typeof row.properties === 'object' && !Array.isArray(row.properties)
+ ? (row.properties as Record)
+ : {},
+ }))
+ } catch (err) {
+ inspectorError.value = err instanceof Error ? err.message : 'Failed to load graph inspector'
+ } finally {
+ inspectorLoading.value = false
+ }
+}
+
+async function generateAssistantDraft() {
+ const prompt = assistantPrompt.value.trim()
+ if (!prompt || !selectedKnowledgeGraphId.value) return
+ assistantBusy.value = true
+ try {
+ assistantMessages.value.push({
+ id: `user-${Date.now()}`,
+ role: 'user',
+ text: prompt,
+ })
+ const lowered = prompt.toLowerCase()
+ const opType = lowered.includes('delete')
+ ? 'DELETE'
+ : lowered.includes('update')
+ ? 'UPDATE'
+ : 'CREATE'
+ const draft = opType === 'DELETE'
+ ? `{"op":"DELETE","type":"node","id":"entity:replace-me"}`
+ : opType === 'UPDATE'
+ ? `{"op":"UPDATE","type":"node","id":"entity:replace-me","set_properties":{"status":"updated"}}`
+ : [
+ `{"op":"DEFINE","type":"node","label":"manual_entity","description":"Manual entity added from assistant","required_properties":["name"]}`,
+ `{"op":"CREATE","type":"node","label":"manual_entity","id":"manual_entity:${generateHexId()}","set_properties":{"name":"Manual Draft","slug":"manual-draft","source_path":"assistant","data_source_id":"manual"}}`,
+ ].join('\n')
+
+ await insertTemplate(draft)
+ const parsed = parseContent(draft)
+ markEditedFromOperations(parsed.operations)
+ assistantMessages.value.push({
+ id: `assistant-${Date.now()}`,
+ role: 'assistant',
+ text: 'Draft inserted into the editor. Review fields highlighted in the inspector, then apply on submit.',
+ })
+ assistantPrompt.value = ''
+ inspectorTab.value = 'entities'
+ } finally {
+ assistantBusy.value = false
+ }
+}
+
// ββ Actions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function insertTemplate(content: string) {
@@ -373,6 +578,8 @@ async function handleSubmit() {
return t && !t.startsWith('//') && !t.startsWith('#')
})
const body = cleanLines.join('\n')
+ const parsedLarge = parseContent(body)
+ markEditedFromOperations(parsedLarge.operations)
preparing.value = false
submission.submit(selectedKnowledgeGraphId.value, body, opCount)
return
@@ -392,6 +599,7 @@ async function handleSubmit() {
// Convert parsed operations to clean JSONL for submission
const jsonlBody = toJsonl(result.operations)
+ markEditedFromOperations(result.operations)
submission.submit(selectedKnowledgeGraphId.value, jsonlBody, result.operations.length)
}
@@ -860,6 +1068,134 @@ onBeforeUnmount(() => {
+
+
+
+
+ Manual Mutation Assistant + Live Graph Inspector
+
+
+
+
+
+ Entities
+ Relationships
+
+
+
+
+ Loading live entity inspector...
+
+
+ {{ inspectorError }}
+
+
+ No entities found for this knowledge graph.
+
+
+
+
{{ row.type }} Β· {{ row.label }}
+
+
+ {{ propKey }}={{ String(propValue) }}
+
+
+
+
+
+
+
+
+ Loading live relationship inspector...
+
+
+ {{ inspectorError }}
+
+
+ No relationships found for this knowledge graph.
+
+
+
+
{{ row.type }} Β· {{ row.source }} -> {{ row.target }}
+
+
+ {{ propKey }}={{ String(propValue) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Conversation applies drafts immediately into the editor.
+
+
+
+
{{ message.role === 'assistant' ? 'Assistant' : 'You' }}
+
{{ message.text }}
+
+
+
+
+
+
+ Draft
+
+
+
+ Refresh
+
+
+
+
+
+
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/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..27e34198b
--- /dev/null
+++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/index.vue
@@ -0,0 +1,998 @@
+
+
+
+
+
+
+ Back to workspace overview
+
+
+
+
+
+
+
+
Data Sources
+
+ {{ kgName }} β
+ Manage connected repositories, sync runs, and commit tracking.
+
+
+
+
+
+ Ready
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add repositories
+
+
+ Paste Git URLs (HTTPS or git@ ). Private repos need a token below.
+
+
+
+
+
+
+
+
+
+
+ GitHub access token (optional, for new private repos)
+
+
+
+
+
+ Add another
+
+
+
+ Add to project
+
+
+
+
+
+
+
+
+ Maintenance focus
+
+ Showing sources with new commits since the last extraction baseline.
+
+
+
+
+
+
+
+
+
+
+ Data sources overview
+
+
+ Updatingβ¦
+
+
+
+
+
+
+
+ Refresh data
+
+
+
+
+ Check for new commits
+
+
+
+ Prepare data sources
+
+
+
+
+ Check for new commits resolves the remote branch tip (like after
+ git fetch ) and shows the newest commit you
+ have not ingested yet. Prepare pulls that content into a JobPackage.
+
+
+
+
+ No sources need maintenance right now.
+ No data sources to display.
+
+
+
+
+
+
+ Source
+ Branch
+ Status
+ Files on branch
+ Last extraction baseline
+ Ingested at
+ Newest unpulled
+ Actions
+
+
+
+
+
+ {{ ds.name }}
+
+ {{ resolveRepoUrl(ds.connection_config) }}
+
+
+
+ {{ resolveTrackedBranch(ds.connection_config) }}
+
+
+
+ {{ resolvePrepStatusLabel(latestStatus(ds)) }}
+
+
+
+
+
+
+ {{ formatPreparedFileCount(ds.last_prepared_file_count) }}
+
+
+
+
+ {{ shortCommitHash(ds.last_extraction_baseline_commit) }}
+
+
+
+ {{
+ commitStatusLabel(
+ ds.last_extraction_baseline_commit,
+ ds.tracked_branch_head_commit,
+ )
+ }}
+
+
+
+
+ {{ shortCommitHash(resolveIngestedHeadCommit(ds)) }}
+
+
+ {{ resolveIngestedHeadCommit(ds) ? 'have locally' : 'nothing ingested yet' }}
+
+
+
+
+
+ {{ shortCommitHash(resolveNewestUnpulledCommit(ds)) }}
+
+
+
+ {{
+ unpulledCommitStatusLabel(
+ resolveNewestUnpulledCommit(ds),
+ resolveBranchTipCommit(ds),
+ )
+ }}
+
+
+
+
+
+
+ Edit
+
+
+
+ Delete
+
+
+
+
+
+
+ {{ ds.diff_summary.total_changed_files }}
+ changed files
+
+
+ {{ hasUnpulledCommits(ds) ? 'Unpulled commits' : 'Up to date' }}
+
+
+
+ {{ isDiffExpanded(ds.id) ? 'Hide files' : 'Show files' }}
+
+
+
+ {{ file.path }}
+ {{ file.status }}
+
+
+
+
+
+
+
+ {{ new Date(run.started_at).toLocaleString() }}
+
+
+ Logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Data Sources ready
+
+
+ {{ preparedCount }} of {{ dataSources.length }} source{{ dataSources.length === 1 ? '' : 's' }}
+ prepared for graph management and extraction.
+
+
+
+
+ Ingestion context is prepared. Open graph management to design schema, run extraction,
+ or continue in the manage workspace.
+
+
+
+ Open Graph Management
+
+
+
+
+
+
+ Back to workspace overview
+
+
+
+
+
+
+
+ Flow: add repository URLs above, check for new commits to resolve branch heads,
+ then prepare data sources before opening graph management.
+
+
+
+
+
+
+
+ Edit configuration
+ Update name or rotate credentials.
+
+
+
+
Name
+
+
{{ editConfigNameError }}
+
+
+ New access token (optional)
+
+
+
+
+ Save
+
+
+
+
+
+
+
+
+ Sync logs
+ Run {{ selectedLogRunId }}
+
+
+
+
{{ logsError }}
+
{{ runLogs.join('\n') || 'No log lines.' }}
+
+
+
+
+
+
+
+ Delete data source?
+
+ This permanently deletes "{{ deletingDs?.name }}" and its sync history.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
+
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..331d775e6
--- /dev/null
+++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/data-sources/new.vue
@@ -0,0 +1,697 @@
+
+
+
+
+
+
+ Back to workspace overview
+
+
+
+ Select a tenant from the sidebar to connect data sources.
+
+
+
+
+
+
+
+
+ Add data sources
+
+
+ Connect Git repositories to
+ {{ kgName }}
+ loading⦠.
+ You will confirm branch and credentials next, then run an initial sync.
+
+
+
+
+
+
+ Add another URL
+
+ {{ sourceUrlError }}
+ {{ providerError }}
+
+ GitHub repositories are supported today. You can add more sources later from the
+ data sources page.
+
+
+
+
+
+ Continue
+
+
+
+
+
+
+
+
+
+
+ Configure each repository
+
+
+ Review names and tracked branches. Use one access token for all private repos if needed.
+
+
+
+
+
{{ entry.url }}
+
+
+
Name
+
+
{{ entry.nameError }}
+
+
+
Tracked branch
+
+
{{ entry.branchError }}
+
+
+
{{ entry.urlError }}
+
+
+
GitHub access token (optional)
+
+
+ Required for private repositories. Applied to all sources in this batch.
+
+
+
+
+ Back
+
+
+
+ Connect data sources
+
+
+
+
+
+
+
+
+
+ {{ kgName }}
+ sources connected
+
+ Prepare ingestion context
+
+ Fetch repository content and build job packages for each source. No AI extraction
+ runs here β that happens later in graph management. Sources are prepared in parallel.
+
+
+
+
+
+
+
+ Prepare ingestion context
+
+
+
+
+
+ Preparing {{ syncActiveName || 'β¦' }}
+ {{ syncStepLabel }}
+
+
+
+
+
+
+
+
+
{{ source.name }}
+
{{ source.url }}
+
{{ source.syncError }}
+
+
+
+
+ {{ getSyncBadge(source.syncStatus).label }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Preparation summary
+
+
+ Ingestion context is ready for all sources. Open data sources to manage commits, or
+ continue in graph management when you are ready to extract.
+
+
+
+
+
+
+
+ Data source
+ Status
+
+
+
+
+ {{ s.name }}
+
+ Prepared
+
+
+
+
+
+
+ {{ preparedSourceCount }}
+ source{{ preparedSourceCount === 1 ? '' : 's' }} ready for later extraction.
+
+
+
+
+
+ Open data sources
+
+
+
+
+
+ Back to workspace overview
+
+
+
+
+
+
+
+
+
diff --git a/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue
new file mode 100644
index 000000000..368c7bdd7
--- /dev/null
+++ b/src/dev-ui/app/pages/knowledge-graphs/[kgId]/manage.vue
@@ -0,0 +1,2353 @@
+
+
+
+
+
+
+
+ Back to Knowledge Graphs
+
+
+
+
+
+
+
+
{{ graphHeaderTitle }}
+ {{ stepBadgeLabel }}
+
+
+
+ Conversation-first graph management with shared session and mode-specific workspace panels.
+
+
+ Knowledge-graph scoped mutation run visibility and run metrics.
+
+
+
+
+
+ Back to workspace overview
+
+
+
+
+
+
+ Select a tenant to manage this workspace.
+
+
+
+
+ {{ workspaceOverviewState.message }}
+
+
+
+
{{ workspaceOverviewState.title }}
+
{{ workspaceOverviewState.message }}
+
+
+
+
{{ workspaceOverviewState.title }}
+
{{ workspaceOverviewState.message }}
+
+ Retry workspace load
+
+
+
+
+
+
+
+
+
+
{{ graphHeaderTitle }}
+
{{ kgId }}
+
+ {{ kgIdentity.description }}
+
+
+
+
+
+
+ Delete
+
+
+ {{ workspaceHubPhaseBadge.label }}
+
+
+
+
+
+
+
+
+ Project workspace
+ {{ workspaceHubDescriptionText }}
+
+
+
+
+
+ {{ workspaceHubNextStep.primaryPhase ? 'Next step' : 'Suggested next step' }}
+
+
{{ workspaceHubNextStep.title }}
+
{{ workspaceHubNextStep.description }}
+
+
+
+ {{ workspaceHubNextStep.label }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+ {{ item.step }}
+
+
+ {{ item.subtitle }}
+
+ {{ item.linkLabel }}
+
+
+
+
+
+
+ {{ item.title }}
+
+
+ {{ item.step }}
+
+
+
{{ item.subtitle }}
+
{{ item.lockedReason }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ dataSourceCount }}
+
Data Sources
+
+
+
+
+
+
+
+
+
+
{{ entityTypeLabels.length }}
+
Entity Types
+
+
+
+
+
+
+
+
+
+
{{ relationshipTypeLabels.length }}
+
Relationship Types
+
+
+
+
+
+
+
+
+
+
{{ mutationLogRuns.length }}
+
Mutation Runs
+
+
+
+
+
+
+
+ Data Sources
+ Configured repositories for this knowledge graph
+
+
+
+ No data sources configured yet.
+
+
+
+
+
+
+
{{ source.name }}
+
{{ source.url }}
+
+
+
{{ source.status }}
+
+
+
+
+
+
+
+
+ Entity Types
+ Node types in the knowledge graph ontology
+
+
+
+ No entity types defined yet.
+
+
+
+ {{ label }}
+
+
+
+
+
+
+ Relationship Types
+ Edge types connecting entities
+
+
+
+ No relationship types defined yet.
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
{{ mutationLogsSectionState.title }}
+
{{ mutationLogsSectionState.message }}
+
+
+
{{ mutationLogsSectionState.title }}
+
{{ mutationLogsSectionState.message }}
+
+ Retry mutation log load
+
+
+
+
+ MutationLogs
+
+ Knowledge-graph scoped mutation runs with per-entry operation previews and run metrics.
+
+
+
+
+
+
+
+ {{ mutationLogsSectionState.message }}
+
+
+
{{ mutationLogsSectionState.message }}
+
+ {{ mutationLogsSectionState.actionLabel ?? 'Refresh runs' }}
+
+
+
+
+ {{ run.data_source_name }}
+ {{ new Date(run.started_at).toLocaleString() }}
+
+ {{ run.status }}
+ {{ run.mutation_log_id }}
+
+
+
+
+
+
+
Run summary
+
+
{{ selectedMutationLogRun.status }}
+
+ Data source:
+ {{ selectedMutationLogRun.data_source_name }}
+
+
+
+
+
MutationLog
+
{{ selectedMutationLogRun.mutation_log_id }}
+
+
+
Session
+
{{ selectedMutationLogRun.session_id ?? 'None' }}
+
+
+
Started
+
{{ new Date(selectedMutationLogRun.started_at).toLocaleString() }}
+
+
+
Completed
+
+ {{ selectedMutationLogRun.completed_at ? new Date(selectedMutationLogRun.completed_at).toLocaleString() : 'In progress' }}
+
+
+
+
+
+
+
+ Token usage
+
+
{{ (selectedMutationLogRun.token_usage_total ?? 0).toLocaleString() }}
+
+
+
+
+ Cost (USD)
+
+
${{ (selectedMutationLogRun.cost_total_usd ?? 0).toFixed(2) }}
+
+
+
+
Operation class counts
+
+ No operation class counts recorded for this run.
+
+
+
+ {{ opClass }}
+ {{ count }}
+
+
+
+
+
+
Per-entry operation previews
+
+
+ Previous
+
+
+ Next
+
+
+
+
+
+ Loading entry previews...
+
+
+ {{ MUTATION_LOG_NO_PREVIEW_MESSAGE }}
+
+
+
+
+ {{ entry.operation_class }}
+ Line {{ entry.line_number }}
+
+
{{ entry.summary }}
+
+
+
+
+
+ Select a mutation run to view summary and per-entry previews.
+
+
+
+
+
+
+
+
{{ graphManagementSectionState.title }}
+
{{ graphManagementSectionState.message }}
+
+ Retry session load
+
+
+
+
+
+
+
+
+ Graph Management
+
+ Shared chat session with mode-specific assistant framing and workspace panels.
+
+
+
+
+
+
Mode:
+
+
+
+ {{ GRAPH_MANAGEMENT_MODE_LABELS[mode] }}
+
+
+
+
+
+ {{ GRAPH_MANAGEMENT_MODE_LABELS[mode] }}
+
+
+
+ {{ graphManagementModeLockReason(mode, graphManagementModeGate) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Schema & artifacts
+
+ Workspace signals for
+ {{ graphManagementModeLabel }} .
+ Select an artifact to open it in the detail panel to the right.
+
+
+
+
+
+ {{ item.label }}
+ {{ graphManagementArtifactHint(item) }}
+
+
+
+ No schema artifacts for this mode.
+
+
+
+
+
+
+
+
+
+
+
+
+ Delete this knowledge graph?
+
+ This permanently deletes
+ {{ kgIdentity?.name ?? kgId }}
+ and its configuration. Data sources and sync history for this graph will be removed.
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+
+
diff --git a/src/dev-ui/app/pages/knowledge-graphs/index.vue b/src/dev-ui/app/pages/knowledge-graphs/index.vue
index e67e5ff96..d994f5021 100644
--- a/src/dev-ui/app/pages/knowledge-graphs/index.vue
+++ b/src/dev-ui/app/pages/knowledge-graphs/index.vue
@@ -8,7 +8,6 @@ import {
Loader2,
Cable,
Database,
- Pencil,
Trash2,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
@@ -82,14 +81,6 @@ const creating = ref(false)
const createNameError = ref('')
const createWorkspaceError = ref('')
-// Edit dialog
-const editDialogOpen = ref(false)
-const editingKgId = ref('')
-const editName = ref('')
-const editDescription = ref('')
-const editNameError = ref('')
-const saving = ref(false)
-
// Delete dialog
const deleteDialogOpen = ref(false)
const deletingKgId = ref('')
@@ -164,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,
})
@@ -186,42 +177,6 @@ async function handleCreate() {
}
}
-function openEditDialog(kg: KnowledgeGraphItem) {
- editingKgId.value = kg.id
- editName.value = kg.name
- editDescription.value = kg.description ?? ''
- editNameError.value = ''
- editDialogOpen.value = true
-}
-
-async function handleEdit() {
- editNameError.value = ''
- if (!editName.value.trim()) {
- editNameError.value = 'Knowledge graph name is required'
- return
- }
- saving.value = true
- try {
- const { apiFetch } = useApiClient()
- await apiFetch(`/management/knowledge-graphs/${editingKgId.value}`, {
- method: 'PATCH',
- body: {
- name: editName.value.trim(),
- description: editDescription.value.trim(),
- },
- })
- toast.success('Knowledge graph updated')
- editDialogOpen.value = false
- await loadKnowledgeGraphs()
- } catch (err) {
- toast.error('Failed to update knowledge graph', {
- description: extractErrorMessage(err),
- })
- } finally {
- saving.value = false
- }
-}
-
function openDeleteDialog(kg: KnowledgeGraphItem) {
deletingKgId.value = kg.id
deletingKgName.value = kg.name
@@ -370,18 +325,14 @@ watch(tenantVersion, () => {
-
-
- Add Data Source
+
+
+ Manage
Query
-
-
- Edit
-
{
-
-
-
-
- Edit Knowledge Graph
-
- Update the name or description of this knowledge graph.
-
-
-
-
-
-
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 03c1a8d33..f576c0cb3 100644
--- a/src/dev-ui/app/tests/data-sources.test.ts
+++ b/src/dev-ui/app/tests/data-sources.test.ts
@@ -2023,6 +2023,101 @@ describe('Backend API Alignment β Scenario: Resource operations succeed end-to
})
})
+describe('Source commit refresh actions', () => {
+ 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: {
+ 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
@@ -3043,3 +3138,117 @@ describe('Data Sources β kg_id query param pre-selects KG and opens wizard (Ta
expect(source).toMatch(/openWizard\s*\([^)]*preselectedKgId/)
})
})
+
+describe('Data-sources-focused layout - structural verification', () => {
+ const { readFileSync } = require('fs')
+ const { resolve } = require('path')
+ const source = readFileSync(
+ resolve(__dirname, '../pages/data-sources/index.vue'),
+ 'utf-8',
+ )
+
+ 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('removes scheduled maintenance orchestration from this page', () => {
+ expect(source).not.toContain('Scheduled maintenance orchestration')
+ expect(source).not.toContain('maintenance-runs/trigger')
+ })
+
+ 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')
+ })
+
+ it('uses shadcn Select for knowledge graph dropdown styling consistency', () => {
+ expect(source).toContain('')
+ expect(source).toContain('SelectTrigger')
+ expect(source).toContain('SelectContent')
+ expect(source).not.toContain(' {
+ 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
+
+ for (const entry of pendingSources) {
+ try {
+ await createDataSource(entry)
+ successCount += 1
+ } catch {
+ failedIds.push(entry.id)
+ }
+ }
+
+ const remaining = pendingSources.filter((entry) => failedIds.includes(entry.id))
+ expect(successCount).toBe(2)
+ expect(remaining.map((entry) => entry.id)).toEqual(['2'])
+ })
+})
+
+describe('Commit-hash status cues - structural verification', () => {
+ const source = readFileSync(
+ resolve(__dirname, '../pages/data-sources/index.vue'),
+ 'utf-8',
+ )
+
+ it('renders commit status section with canonical commit labels', () => {
+ expect(source).toContain('Commit Status')
+ 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', () => {
+ expect(source).toContain('isMaintenanceReady')
+ expect(source).toContain('New commits available')
+ expect(source).toContain('Up to date')
+ })
+})
+
+describe('Maintenance readiness with commit-diff semantics - structural verification', () => {
+ const source = readFileSync(
+ resolve(__dirname, '../pages/data-sources/index.vue'),
+ 'utf-8',
+ )
+
+ it('derives readiness from baseline-vs-tracked-head commit comparison', () => {
+ expect(source).toContain('function isMaintenanceReady')
+ expect(source).toContain('last_extraction_baseline_commit')
+ expect(source).toContain('tracked_branch_head_commit')
+ expect(source).toContain('!==')
+ })
+
+ it('renders maintenance readiness badge and diff summary counts', () => {
+ expect(source).toContain('isMaintenanceReady(ds) ? \'New commits available\' : \'Up to date\'')
+ expect(source).toContain('changed files')
+ expect(source).toContain('added_count')
+ expect(source).toContain('modified_count')
+ expect(source).toContain('removed_count')
+ expect(source).toContain('renamed_count')
+ })
+
+ it('keeps changed-file list collapsed by default and expandable on demand', () => {
+ expect(source).toContain('Changed-file list is collapsed by default')
+ expect(source).toContain('Show changed files')
+ expect(source).toContain('Hide changed files')
+ expect(source).toContain('isDiffExpanded')
+ })
+})
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/kg-data-sources-phase1.test.ts b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts
new file mode 100644
index 000000000..ec85d18c8
--- /dev/null
+++ b/src/dev-ui/app/tests/kg-data-sources-phase1.test.ts
@@ -0,0 +1,118 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'fs'
+import { resolve } from 'path'
+import {
+ hasUnpulledCommits,
+ isIngestionPreparedAtHead,
+ needsIngestionPrepare,
+ prepStatusBadgeVariant,
+ resolveNewestUnpulledCommit,
+ resolvePrepStatusLabel,
+ resolveRepoUrl,
+ shortCommitHash,
+ unpulledCommitStatusLabel,
+} 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('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')
+ 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')
+ })
+
+ it('refreshes data sources silently while polling', () => {
+ expect(phase1Vue).toContain('loadDataSources({ silent: true })')
+ expect(phase1Vue).toContain('refreshing')
+ 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')
+ expect(phase1Vue).toContain('Ingested at')
+ expect(phase1Vue).not.toContain('Branch tip')
+ expect(phase1Vue).toContain('resolveNewestUnpulledCommit')
+ })
+})
+
+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(
+ 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(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-extraction-chat.test.ts b/src/dev-ui/app/tests/kg-extraction-chat.test.ts
new file mode 100644
index 000000000..e1155a39f
--- /dev/null
+++ b/src/dev-ui/app/tests/kg-extraction-chat.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest'
+import { streamExtractionChatTurn, streamRuntimeWarmup } 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
+ }
+ })
+
+ 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
+ }
+ })
+
+ 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/kg-graph-management-artifacts.test.ts b/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts
new file mode 100644
index 000000000..e89b76a4e
--- /dev/null
+++ b/src/dev-ui/app/tests/kg-graph-management-artifacts.test.ts
@@ -0,0 +1,42 @@
+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,
+ hasMinimumEntityTypes: false,
+ hasMinimumRelationshipTypes: false,
+ 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-entities')
+ 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/kg-manage-workspace-hub.test.ts b/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts
new file mode 100644
index 000000000..7bf307675
--- /dev/null
+++ b/src/dev-ui/app/tests/kg-manage-workspace-hub.test.ts
@@ -0,0 +1,94 @@
+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('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,
+ 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('Graph Management')
+ })
+
+ 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('Graph Management')
+ })
+})
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/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> = {}) {
+ 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
new file mode 100644
index 000000000..19b93d99c
--- /dev/null
+++ b/src/dev-ui/app/tests/knowledge-graph-manage-workspace.test.ts
@@ -0,0 +1,652 @@
+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'
+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'),
+ '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 manageWorkspaceHubTs = readFileSync(
+ resolve(__dirname, '../utils/kgManageWorkspaceHub.ts'),
+ '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 - graph management controls', () => {
+ it('loads workspace status projection from management API', () => {
+ expect(manageWorkspaceVue).toContain('/workspace-status')
+ expect(manageWorkspaceVue).toContain('loadWorkspaceStatus')
+ })
+
+ it('exposes Validate action calling workspace validate endpoint', () => {
+ expect(manageWorkspaceVue).toContain('validateWorkspace')
+ expect(manageWorkspaceVue).toContain('/workspace/validate')
+ expect(manageWorkspaceVue).toContain('Validate')
+ })
+
+ it('exposes Go to Extraction/Mutations action calling transition endpoint', () => {
+ expect(manageWorkspaceVue).toContain('transitionToExtraction')
+ expect(manageWorkspaceVue).toContain('/workspace/transition-to-extraction')
+ expect(manageWorkspaceVue).toContain('Go to Extraction/Mutations')
+ })
+
+ it('loads scoped session history with run metrics after clear chat', () => {
+ expect(manageWorkspaceVue).toContain('loadSessionHistory')
+ expect(manageWorkspaceVue).toContain('/sessions/${sharedSessionMode.value}/history')
+ expect(manageWorkspaceVue).toContain('sessionHistory')
+ expect(manageWorkspaceVue).toContain('run_metrics')
+ expect(manageWorkspaceVue).toContain('Session history')
+ })
+})
+
+describe('Knowledge Graph Manage Workspace - mutation log 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')
+ })
+
+ 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('collectScopedMutationLogRuns')
+ })
+
+ it('renders run detail summary with token and cost metrics', () => {
+ expect(manageWorkspaceVue).toContain('Token usage')
+ expect(manageWorkspaceVue).toContain('Cost (USD)')
+ expect(manageWorkspaceVue).toContain('token_usage_total')
+ expect(manageWorkspaceVue).toContain('cost_total_usd')
+ })
+
+ 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('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')
+ })
+})
+
+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('progressChecklist')
+ expect(manageWorkspaceVue).toContain('Minimum entity types')
+ expect(manageWorkspaceVue).toContain('Minimum relationship types')
+ expect(manageWorkspaceVue).toContain('Prepopulated instance coverage')
+ })
+
+ it('renders diagnostics panel with prepopulated type failures and blocking reasons', () => {
+ 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('Run Validate to refresh readiness signals')
+ expect(manageWorkspaceVue).toContain('Transition is enabled')
+ })
+})
+
+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 hub tile set', () => {
+ it('renders Project workspace section with hub tiles and stats', () => {
+ expect(manageWorkspaceVue).toContain('Project workspace')
+ 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('Graph Management')
+ expect(manageWorkspaceHubTs).toContain('Mutation logs')
+ expect(manageWorkspaceHubTs).toContain('Maintain')
+ })
+
+ 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 next-step callout in the workspace hub card', () => {
+ expect(manageWorkspaceVue).toContain('Suggested next step')
+ expect(manageWorkspaceVue).toContain('workspaceHubNextStep')
+ expect(manageWorkspaceVue).toContain('Next step')
+ })
+
+ 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 - 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 in graph-management rail', () => {
+ 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('keeps data-sources route utility for workspace cards but not graph-management redirects', () => {
+ expect(manageWorkspaceVue).not.toContain('navigateTo(buildDataSourcesStepUrl(kgId))')
+ 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('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')
+ expect(kgDataSourcesIndex).toContain('Data sources overview')
+ expect(kgDataSourcesIndex).toContain('max-w-7xl')
+ })
+})
+
+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(
+ '/knowledge-graphs/kg-abc/data-sources?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 phase-2 style conversational intelligence header and resume action', () => {
+ expect(sharedConversationPanelVue).toContain('Graph Management Assistant')
+ expect(sharedConversationPanelVue).toContain('Resume session')
+ expect(sharedConversationPanelVue).toContain('Sparkles')
+ })
+
+ 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 bubble chat, thinking state, and auto-scroll', () => {
+ expect(sharedConversationPanelVue).toContain('thinkingDisplayLines')
+ 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')
+ })
+})
+
+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 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')
+ 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')
+ expect(manageWorkspaceVue).toContain('isGraphManagementModeUnlocked')
+ expect(manageWorkspaceVue).toContain('graphManagementModeLockReason')
+ })
+
+ 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 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', () => {
+ const items = buildGraphManagementRailItems({
+ workspaceMode: 'schema_bootstrap',
+ transitionEligible: false,
+ blockingReasonCount: 1,
+ prepopulatedGapCount: 0,
+ hasMinimumEntityTypes: false,
+ hasMinimumRelationshipTypes: false,
+ 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-artifact-detail')
+ expect(manageWorkspaceVue).toContain('graph-management-detail')
+ expect(manageWorkspaceVue).toContain('selectedRailItemId')
+ expect(manageWorkspaceVue).toContain("selectedRailItemId === 'schema-readiness'")
+ expect(manageWorkspaceVue).toContain("selectedRailItemId === 'extraction-jobs-setup'")
+ expect(manageWorkspaceVue).toContain("selectedRailItemId === 'mutation-authoring'")
+ })
+
+ it('filters rail items to the active mode', () => {
+ const items = buildGraphManagementRailItems({
+ workspaceMode: 'extraction_operations',
+ transitionEligible: true,
+ blockingReasonCount: 0,
+ prepopulatedGapCount: 0,
+ hasMinimumEntityTypes: true,
+ hasMinimumRelationshipTypes: true,
+ 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('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')
+ 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,
+ hasMinimumEntityTypes: true,
+ hasMinimumRelationshipTypes: true,
+ 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('handleComposerEnter')
+ 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('streamRuntimeWarmup')
+ expect(manageWorkspaceVue).toContain('warmupAssistantRuntime')
+ expect(manageWorkspaceVue).toContain('preparing-runtime')
+ expect(manageWorkspaceVue).toContain('conversationSessionForPanel')
+ expect(manageWorkspaceVue).toContain('@send-message="sendChatMessage"')
+ })
+})
+
+describe('KG-MANAGE-018 - keyboard operable step and rail actions', () => {
+ it('uses native links for workspace hub tiles', () => {
+ expect(manageWorkspaceVue).toContain('workspaceHubTiles')
+ expect(manageWorkspaceVue).toContain(' {
+ expect(manageWorkspaceVue).toContain('onSchemaRailKeydown')
+ expect(manageWorkspaceVue).toContain('@keydown="onSchemaRailKeydown($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')
+ })
+})
+
+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`)')
+ })
+})
diff --git a/src/dev-ui/app/tests/knowledge-graphs.test.ts b/src/dev-ui/app/tests/knowledge-graphs.test.ts
index 1a2a52287..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
}
@@ -1468,21 +1467,10 @@ describe('Knowledge Graphs page β edit and delete structural checks', () => {
'utf-8',
)
- it('declares editDialogOpen state', () => {
- expect(kgVue).toMatch(/editDialogOpen/)
- })
-
it('declares deleteDialogOpen state', () => {
expect(kgVue).toMatch(/deleteDialogOpen/)
})
- it('calls PATCH /management/knowledge-graphs/ in handleEdit', () => {
- // Check that both PATCH method and knowledge-graphs URL path appear in the file
- // (they may be on separate lines in multi-line API call syntax)
- expect(kgVue).toContain("method: 'PATCH'")
- expect(kgVue).toContain('/management/knowledge-graphs/')
- })
-
it('calls DELETE /management/knowledge-graphs/ in handleDelete', () => {
// Check that both DELETE method and knowledge-graphs URL path appear in the file
// (they may be on separate lines in multi-line API call syntax)
@@ -1490,10 +1478,6 @@ describe('Knowledge Graphs page β edit and delete structural checks', () => {
expect(kgVue).toContain('/management/knowledge-graphs/')
})
- it('edit dialog is present in the template', () => {
- expect(kgVue).toMatch(/editDialogOpen|Edit Knowledge Graph/)
- })
-
it('delete AlertDialog is present in the template', () => {
expect(kgVue).toMatch(/AlertDialog|deleteDialogOpen/)
})
@@ -1502,16 +1486,33 @@ describe('Knowledge Graphs page β edit and delete structural checks', () => {
expect(kgVue).toMatch(/data sources?/i)
})
- it('handleEdit calls loadKnowledgeGraphs after success', () => {
- expect(kgVue).toContain('handleEdit')
- expect(kgVue).toContain('loadKnowledgeGraphs')
- })
-
it('handleDelete calls loadKnowledgeGraphs after success', () => {
expect(kgVue).toContain('handleDelete')
})
})
+describe('Knowledge Graphs page - manage/query/delete row action set', () => {
+ const kgVue = readFileSync(
+ resolve(__dirname, '../pages/knowledge-graphs/index.vue'),
+ 'utf-8',
+ )
+
+ it('renders Manage, Query, and Delete row actions', () => {
+ expect(kgVue).toContain('Manage')
+ expect(kgVue).toContain('Query')
+ expect(kgVue).toContain('Delete')
+ })
+
+ it('does not render legacy Add Data Source or Edit row buttons', () => {
+ expect(kgVue).not.toMatch(/ {
+ expect(kgVue).toContain('navigateTo(`/knowledge-graphs/${kg.id}/manage`)')
+ })
+})
+
// ββ Backend API Alignment: KG creation list refresh βββββββββββββββββββββββββββ
//
// Spec: "AND the UI reflects the updated state without requiring a manual refresh"
diff --git a/src/dev-ui/app/tests/mutations-console.test.ts b/src/dev-ui/app/tests/mutations-console.test.ts
index e22cab53f..840ea5c0c 100644
--- a/src/dev-ui/app/tests/mutations-console.test.ts
+++ b/src/dev-ui/app/tests/mutations-console.test.ts
@@ -1124,6 +1124,35 @@ describe('Mutations Console - navigation placement', () => {
})
})
+describe('Mutations Console - manual mutation assistant and live inspector', () => {
+ it('renders the assistant + inspector panel title', () => {
+ expect(mutationsVue).toContain('Manual Mutation Assistant + Live Graph Inspector')
+ })
+
+ it('includes entity and relationship inspector tabs', () => {
+ expect(mutationsVue).toContain('TabsTrigger value="entities"')
+ expect(mutationsVue).toContain('TabsTrigger value="relationships"')
+ })
+
+ it('loads live inspector data through queryGraph for selected knowledge graph', () => {
+ expect(mutationsVue).toContain('loadLiveInspector')
+ expect(mutationsVue).toContain('queryGraph(')
+ expect(mutationsVue).toContain('selectedKnowledgeGraphId.value')
+ })
+
+ it('tracks edited session fields/types and applies highlight styling', () => {
+ expect(mutationsVue).toContain('sessionEditedTypes')
+ expect(mutationsVue).toContain('sessionEditedFields')
+ expect(mutationsVue).toContain('border-amber-500/70 bg-amber-500/10')
+ })
+
+ it('clears edited highlights after successful apply and refreshes inspector', () => {
+ expect(mutationsVue).toContain('resetSessionEditHighlights')
+ expect(mutationsVue).toContain("if (status === 'success')")
+ expect(mutationsVue).toContain('await loadLiveInspector()')
+ })
+})
+
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Design Language β Typography: no font-bold (700) in page files
// Spec: "font weights are limited to regular (400), medium (500), and semibold (600)"
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/tests/task-121-spec-alignment.test.ts b/src/dev-ui/app/tests/task-121-spec-alignment.test.ts
index 4e25cd661..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
@@ -3,9 +3,11 @@ import { readFileSync } from 'fs'
import { resolve } from 'path'
import {
ADAPTERS,
+ detectAdapterFromUrl,
isAdapterSelectable,
canAdvanceStep1,
inferNameFromRepoUrl,
+ validateStep1,
validateStep2,
buildDataSourceCreationUrl,
buildDataSourceCreationBody,
@@ -47,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', () => {
@@ -111,12 +111,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 +149,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 +187,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()
})
})
@@ -387,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')
-
- // 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)
+ expect(postCreationUrl).toBe('/knowledge-graphs/kg-new-789/data-sources/new')
+ expect(navigateTo).toHaveBeenCalledWith('/knowledge-graphs/kg-new-789/data-sources/new')
- // 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/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/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 {
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()
+ 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/src/dev-ui/app/utils/kgDataSourcesCommits.ts b/src/dev-ui/app/utils/kgDataSourcesCommits.ts
new file mode 100644
index 000000000..a1856bd60
--- /dev/null
+++ b/src/dev-ui/app/utils/kgDataSourcesCommits.ts
@@ -0,0 +1,154 @@
+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'
+}
+
+/** 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
+}
+
+/** Remote branch tip from last check (what git pull would reach). */
+export function resolveBranchTipCommit(ds: {
+ tracked_branch_head_commit?: string | null
+}): string | null {
+ return ds.tracked_branch_head_commit ?? 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
+ 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[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 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[0] & {
+ last_prepared_commit?: string | null
+ job_package_available?: boolean | null
+}): boolean {
+ return hasUnpulledCommits(ds) || needsJobPackageRematerialize(ds)
+}
+
+export function isIngestionPreparedAtHead(ds: Parameters[0]): boolean {
+ const tip = resolveBranchTipCommit(ds)
+ const ingested = resolveIngestedHeadCommit(ds)
+ return !!tip && !!ingested && ingested === tip
+}
+
+export function formatPreparedFileCount(count: number | null | undefined): string {
+ if (count === null || count === undefined) return 'β'
+ return count.toLocaleString()
+}
+
+export function resolveRepoUrl(connectionConfig: Record | 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 | 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'
+ }
+}
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..b56757788
--- /dev/null
+++ b/src/dev-ui/app/utils/kgDataSourcesSync.ts
@@ -0,0 +1,42 @@
+export type SyncRunStatus =
+ | 'pending'
+ | 'ingesting'
+ | 'ai_extracting'
+ | 'applying'
+ | 'ingested'
+ | '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 === 'ingested' || 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(runs: T[] | undefined): T | undefined {
+ return runs?.[0]
+}
+
+export function hasAnyActiveSync(
+ sources: T[],
+): boolean {
+ return sources.some((ds) => isActiveSyncStatus(latestSyncRun(ds.sync_runs)?.status))
+}
diff --git a/src/dev-ui/app/utils/kgExtractionChat.ts b/src/dev-ui/app/utils/kgExtractionChat.ts
new file mode 100644
index 000000000..93912c172
--- /dev/null
+++ b/src/dev-ui/app/utils/kgExtractionChat.ts
@@ -0,0 +1,129 @@
+/** Stream graph-management chat turns and proactive runtime warmup over NDJSON. */
+
+import type { GraphManagementMode } from '@/utils/kgGraphManagement'
+
+export interface ExtractionChatStreamEvent {
+ 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 }
+}
+
+export interface StreamExtractionChatOptions {
+ apiBaseUrl: string
+ accessToken: string | null
+ tenantId: string | null
+ kgId: string
+ sessionMode: 'schema_bootstrap' | 'extraction_operations'
+ uiMode: GraphManagementMode
+ message: string
+}
+
+export interface StreamRuntimeWarmupOptions {
+ apiBaseUrl: string
+ accessToken: string | null
+ tenantId: string | null
+ kgId: string
+ sessionMode: 'schema_bootstrap' | 'extraction_operations'
+ uiMode: GraphManagementMode
+}
+
+async function* streamNdjsonPost(
+ url: string,
+ headers: Record,
+ body: Record,
+): AsyncGenerator {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ })
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '')
+ throw new Error(text || `${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 = ''
+ let sawTerminalDone = false
+
+ 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
+ const event = JSON.parse(trimmed) as ExtractionChatStreamEvent
+ if (event.type === 'done') {
+ sawTerminalDone = true
+ }
+ yield event
+ }
+ }
+
+ const tail = buffer.trim()
+ if (tail) {
+ 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.')
+ }
+}
+
+function buildExtractionHeaders(
+ accessToken: string | null,
+ tenantId: string | null,
+): Record {
+ const headers: Record = {
+ '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 {
+ 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 {
+ 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,
+ })
+}
diff --git a/src/dev-ui/app/utils/kgGraphManagement.ts b/src/dev-ui/app/utils/kgGraphManagement.ts
new file mode 100644
index 000000000..dd2f87f44
--- /dev/null
+++ b/src/dev-ui/app/utils/kgGraphManagement.ts
@@ -0,0 +1,221 @@
+import type { StepStatusLabel } from './kgManageWorkspace'
+
+export type GraphManagementMode =
+ | 'initial-schema-design'
+ | 'extraction-jobs'
+ | 'one-off-mutations'
+
+export type GraphManagementRailItemId =
+ | 'schema-entities'
+ | 'schema-relationships'
+ | '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 = {
+ 'initial-schema-design': 'Initial Schema Design',
+ 'extraction-jobs': 'Extraction Jobs',
+ 'one-off-mutations': 'One-off Mutations',
+}
+
+export const GRAPH_MANAGEMENT_INPUT_PLACEHOLDERS: Record = {
+ '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
+ hasMinimumEntityTypes: boolean
+ hasMinimumRelationshipTypes: boolean
+ 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-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',
+ 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}`
+}
+
+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..2e339da08
--- /dev/null
+++ b/src/dev-ui/app/utils/kgGraphManagementArtifacts.ts
@@ -0,0 +1,61 @@
+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-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'
+ }
+ 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
+}
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,
+ 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,
+ 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/app/utils/kgManageWorkspace.ts b/src/dev-ui/app/utils/kgManageWorkspace.ts
new file mode 100644
index 000000000..6e182d51a
--- /dev/null
+++ b/src/dev-ui/app/utils/kgManageWorkspace.ts
@@ -0,0 +1,333 @@
+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'
+
+export const WORKSPACE_STEP_TITLES: Record = {
+ '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, dataSourceCount = 0): string {
+ return resolveKgDataSourcesEntryUrl(kgId, dataSourceCount)
+}
+
+export function buildMaintainStepUrl(kgId: string): string {
+ return buildKgDataSourcesUrl(kgId, { 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,
+ context?: StepDestinationContext,
+): string {
+ const dataSourceCount = context?.dataSourceCount ?? 0
+ switch (stepId) {
+ case 'data-sources':
+ return buildDataSourcesStepUrl(kgId, dataSourceCount)
+ case 'maintain':
+ return buildMaintainStepUrl(kgId)
+ case 'graph-management':
+ case 'mutation-logs':
+ return buildManageStepUrl(kgId, stepId)
+ }
+}
diff --git a/src/dev-ui/app/utils/kgManageWorkspaceHub.ts b/src/dev-ui/app/utils/kgManageWorkspaceHub.ts
new file mode 100644
index 000000000..5a696a132
--- /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: 'Graph Management', 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: 'Graph Management',
+ subtitle: designDone
+ ? 'Schema validated Β· extraction operations available'
+ : sourcesDone
+ ? 'Graph management 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 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),
+ 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 Graph Management. Green tiles mark completed gates; the highlighted tile is your current focus.'
+ }
+ if (!designPhaseComplete(input)) {
+ 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.'
+}
+
+export function buildManageOverviewUrl(kgId: string): string {
+ return buildManageStepUrl(kgId)
+}
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
+ 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,
+ 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(
+ 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,
+): 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
+}
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: {