generated from coulomb/repo-seed
Compare commits
62 Commits
eacfccdffd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 24041bc3ef | |||
| cf00d3bba5 | |||
| 7661146b48 | |||
| 8a9bfcc9bd | |||
| ec991f4ccd | |||
| 434c80c2c3 | |||
| 6ee5542a88 | |||
| 48815b3db9 | |||
| b536741539 | |||
| 63f0398304 | |||
| c7370c360a | |||
| 13a331cdf1 | |||
| eebb1b8c29 | |||
| 020f3c1688 | |||
| 0f3dba6d83 | |||
| cfa3241aed | |||
| ae2302df64 | |||
| fcb41e8c25 | |||
| e4ab64fa54 | |||
| 398f458374 | |||
| 18a5e2d6f0 | |||
| 262682cdf0 | |||
| c421a2a60d | |||
| 68e413905b | |||
| 94c7817339 | |||
| f88e74288d | |||
| ffaaf48fcb | |||
| 0949d4c0d8 | |||
| 279be4ffbd | |||
| 427e63d9df | |||
| 6c0a2d537c | |||
| 4295b537e2 | |||
| b7484615eb | |||
| 1620701ae4 | |||
| c4c38e1697 | |||
| 9ba9eb95da | |||
| 2d22e79c7c | |||
| 39ed5459b9 | |||
| 270033a50d | |||
| bf377788eb | |||
| ab14e77e77 | |||
| 696b628142 | |||
| 83d266965f | |||
| 7a1de91bd7 | |||
| 821b5d6c89 | |||
| acc5bea15b | |||
| 2b0c05ea4c | |||
| 5a7a6ef5ee | |||
| 0fdebc6aa8 | |||
| 323599f2fc | |||
| dff8cfe128 | |||
| 1b33a27a56 | |||
| 661eb01e45 | |||
| 3d5e354ff8 | |||
| 25cda24661 | |||
| 649ab50788 | |||
| ce82ada0fa | |||
| f14c225dd9 | |||
| d68de69fe6 | |||
| 77689fbfb2 | |||
| 0192dc786f | |||
| f48206424e |
@@ -1,11 +1,11 @@
|
|||||||
## First Session Protocol
|
## First Session Protocol
|
||||||
|
|
||||||
Triggered when `get_domain_summary("custodian")` shows **no workstreams**.
|
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||||
The project is registered but work has not yet been structured.
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
**Step 1 — Read, don't write**
|
**Step 1 — Read, don't write**
|
||||||
- `~/the-custodian/canon/projects/custodian/project_charter_v0.1.md` — purpose, scope
|
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||||
- `~/the-custodian/canon/projects/custodian/roadmap_v0.1.md` — planned phases
|
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||||
- Scan repo root: README, directory structure, existing code or docs
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
**Step 2 — Survey in-progress work**
|
**Step 2 — Survey in-progress work**
|
||||||
@@ -28,7 +28,7 @@ create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
|||||||
**Step 5 — Record the setup**
|
**Step 5 — Record the setup**
|
||||||
```
|
```
|
||||||
add_progress_event(
|
add_progress_event(
|
||||||
summary="First session: structured custodian into N workstreams, M tasks",
|
summary="First session: structured infotech into N workstreams, M tasks",
|
||||||
event_type="milestone",
|
event_type="milestone",
|
||||||
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||||
detail={"workstreams": [...], "tasks_created": M}
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
|
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
|
||||||
|
|
||||||
**Domain:** custodian
|
**Domain:** infotech
|
||||||
**Repo slug:** state-hub
|
**Repo slug:** state-hub
|
||||||
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
## Session Protocol
|
## Session Protocol
|
||||||
|
|
||||||
State Hub: http://127.0.0.1:8000
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
**Step 1 — Orient**
|
**Step 1 — Orient**
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ cat .custodian-brief.md
|
|||||||
```
|
```
|
||||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
```
|
```
|
||||||
get_domain_summary("custodian")
|
get_domain_summary("infotech")
|
||||||
```
|
```
|
||||||
If MCP tools are unavailable in the current agent session, use the REST API:
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
```bash
|
```bash
|
||||||
@@ -43,7 +44,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
|
|||||||
|
|
||||||
**Step 4 — Present brief**
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions
|
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||||
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
|
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
|
||||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||||
# Custodian Brief — state-hub
|
# Custodian Brief — state-hub
|
||||||
|
|
||||||
**Domain:** custodian
|
**Domain:** infotech
|
||||||
**Last synced:** 2026-06-07 18:02 UTC
|
**Last synced:** 2026-06-25 14:02 UTC
|
||||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Active Workstreams
|
## Active Workstreams
|
||||||
@@ -21,13 +21,9 @@ Progress: 0/8 done | workstream_id: `8d0c1b5d-44da-4b91-8357-e6526d3e0a85`
|
|||||||
- … and 1 more open tasks
|
- … and 1 more open tasks
|
||||||
|
|
||||||
### Pragmatic State Hub Migration to railiance01
|
### Pragmatic State Hub Migration to railiance01
|
||||||
Progress: 2/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
|
Progress: 6/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
|
||||||
|
|
||||||
**Open tasks:**
|
**Open tasks:**
|
||||||
- ► T03 — Build and push State Hub container image `79908ade`
|
|
||||||
- · T04 — Deploy to cluster and run Alembic migrations `a7baf2eb`
|
|
||||||
- · T05 — Migrate data from WSL2 to cluster `a307dd46`
|
|
||||||
- · T06 — Drill cluster backup restore `03753b88`
|
|
||||||
- · T07 — Cutover: redirect MCP config to cluster `ff1de25e`
|
- · T07 — Cutover: redirect MCP config to cluster `ff1de25e`
|
||||||
- · T08 — Stabilisation period (2 weeks minimum) `e06a59a0`
|
- · T08 — Stabilisation period (2 weeks minimum) `e06a59a0`
|
||||||
- · T09 — Retire WSL2 instance `d75a2d49`
|
- · T09 — Retire WSL2 instance `d75a2d49`
|
||||||
@@ -36,6 +32,6 @@ Progress: 2/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
|
|||||||
## MCP Orientation (when available)
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
If the state-hub MCP server is reachable, call:
|
If the state-hub MCP server is reachable, call:
|
||||||
`get_domain_summary("custodian")`
|
`get_domain_summary("infotech")`
|
||||||
This provides richer cross-domain context.
|
This provides richer cross-domain context.
|
||||||
If the MCP call fails, use this file as your orientation source.
|
If the MCP call fails, use this file as your orientation source.
|
||||||
|
|||||||
30
.repo-classification.yaml
Normal file
30
.repo-classification.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Repo classification (Repo Classification Standard v1.0).
|
||||||
|
|
||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-22'
|
||||||
|
classified_by: human
|
||||||
|
category: tooling
|
||||||
|
domain: infotech
|
||||||
|
secondary_domains:
|
||||||
|
- agents
|
||||||
|
capability_tags:
|
||||||
|
- coordination
|
||||||
|
- knowledge
|
||||||
|
- platform
|
||||||
|
- observability
|
||||||
|
- governance
|
||||||
|
business_stake:
|
||||||
|
- technology
|
||||||
|
- operations
|
||||||
|
- product
|
||||||
|
- intelligence
|
||||||
|
- automation
|
||||||
|
business_mechanics:
|
||||||
|
- coordination
|
||||||
|
- control
|
||||||
|
- operation
|
||||||
|
- adaptation
|
||||||
|
notes: Live coordination service (PostgreSQL+FastAPI+MCP+dashboard); versioned, daily use.
|
||||||
|
infotech with agents secondary; classified product as a reusable, offerable service component.
|
||||||
19
AGENTS.md
19
AGENTS.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
|
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
|
||||||
|
|
||||||
**Domain:** custodian
|
**Domain:** infotech
|
||||||
**Repo slug:** state-hub
|
**Repo slug:** state-hub
|
||||||
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||||
**Workplan prefix:** `STATE-WP-`
|
**Workplan prefix:** `STATE-WP-`
|
||||||
@@ -27,8 +27,8 @@ there is no MCP server for Codex agents.
|
|||||||
# Offline brief — works without hub connection
|
# Offline brief — works without hub connection
|
||||||
cat .custodian-brief.md
|
cat .custodian-brief.md
|
||||||
|
|
||||||
# Active workplans for this domain
|
# Active workstreams for this domain
|
||||||
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||||
| python3 -m json.tool
|
| python3 -m json.tool
|
||||||
|
|
||||||
# Check inbox
|
# Check inbox
|
||||||
@@ -80,7 +80,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||||||
## Session Protocol
|
## Session Protocol
|
||||||
|
|
||||||
**Start:**
|
**Start:**
|
||||||
1. `cat .custodian-brief.md` — domain goal and open workplans (offline-safe)
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read
|
2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read
|
||||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||||
@@ -151,6 +151,11 @@ every repo's agent instructions because it is high-frequency, high-risk, and eas
|
|||||||
get wrong.
|
get wrong.
|
||||||
|
|
||||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workplan Convention (ADR-001)
|
## Workplan Convention (ADR-001)
|
||||||
@@ -176,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
|
|||||||
id: STATE-WP-NNNN
|
id: STATE-WP-NNNN
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "..."
|
title: "..."
|
||||||
domain: custodian
|
domain: infotech
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
owner: codex
|
owner: codex
|
||||||
@@ -187,10 +192,6 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
`state_hub_workstream_id` is the legacy bridge field for the current State Hub
|
|
||||||
database identity. Prefer workplan-named API routes for new client code while
|
|
||||||
this bridge field remains in compatibility use.
|
|
||||||
|
|
||||||
Use `proposed` for a new draft, `ready` after review against current repo
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
state, and `finished` after implementation. `stalled` and `needs_review` are
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
derived health labels, not frontmatter statuses.
|
derived health labels, not frontmatter statuses.
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -20,10 +20,10 @@ with open("pyproject.toml", "rb") as f:
|
|||||||
project = tomllib.load(f)["project"]
|
project = tomllib.load(f)["project"]
|
||||||
|
|
||||||
for dep in project["dependencies"]:
|
for dep in project["dependencies"]:
|
||||||
# llm-connect is currently a local editable test integration in this repo.
|
# llm-connect is a local editable test integration and must not be pulled
|
||||||
# The State Hub API/MCP runtime does not import it, and a container build
|
# into the production image. hub-core is runtime code, but it is installed
|
||||||
# must not depend on /home/worsch existing inside the image.
|
# from the named Docker build context below because it is not published yet.
|
||||||
if dep == "llm-connect":
|
if dep in {"llm-connect", "hub-core"}:
|
||||||
continue
|
continue
|
||||||
print(dep)
|
print(dep)
|
||||||
PY
|
PY
|
||||||
@@ -31,6 +31,11 @@ PY
|
|||||||
RUN uv venv /app/.venv \
|
RUN uv venv /app/.venv \
|
||||||
&& uv pip install --python /app/.venv/bin/python --no-cache -r /tmp/requirements.txt
|
&& uv pip install --python /app/.venv/bin/python --no-cache -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
COPY --from=hub_core_src pyproject.toml /tmp/hub-core/pyproject.toml
|
||||||
|
COPY --from=hub_core_src hub_core/ /tmp/hub-core/hub_core/
|
||||||
|
|
||||||
|
RUN uv pip install --python /app/.venv/bin/python --no-cache /tmp/hub-core
|
||||||
|
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
COPY api/ ./api/
|
COPY api/ ./api/
|
||||||
COPY flows/ ./flows/
|
COPY flows/ ./flows/
|
||||||
|
|||||||
93
Makefile
93
Makefile
@@ -1,7 +1,17 @@
|
|||||||
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile
|
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run
|
||||||
|
|
||||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
|
HELM ?= $(shell command -v helm 2>/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi)
|
||||||
|
KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi)
|
||||||
|
|
||||||
|
RAILIANCE_STATE_HUB_RELEASE ?= state-hub
|
||||||
|
RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub
|
||||||
|
RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub
|
||||||
|
RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml
|
||||||
|
RAILIANCE_STATE_HUB_IMAGE_TAG ?= b536741
|
||||||
|
RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
|
||||||
|
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
|
||||||
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
|
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
|
||||||
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
|
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
|
||||||
|
|
||||||
@@ -61,12 +71,59 @@ dashboard:
|
|||||||
check:
|
check:
|
||||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||||
|
|
||||||
|
railiance-state-hub-render:
|
||||||
|
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||||
|
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||||
|
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||||
|
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG)
|
||||||
|
|
||||||
|
railiance-state-hub-client-dry-run:
|
||||||
|
@set -e; \
|
||||||
|
tmpdir="$$(mktemp -d)"; \
|
||||||
|
trap 'rm -rf "$$tmpdir"' EXIT; \
|
||||||
|
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||||
|
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||||
|
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||||
|
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"
|
||||||
|
|
||||||
|
railiance-state-hub-server-dry-run:
|
||||||
|
@set -e; \
|
||||||
|
tmpdir="$$(mktemp -d)"; \
|
||||||
|
trap 'rm -rf "$$tmpdir"' EXIT; \
|
||||||
|
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||||
|
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||||
|
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||||
|
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||||
|
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||||
|
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||||
|
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||||
|
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||||
|
if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \
|
||||||
|
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||||
|
$(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||||
|
else \
|
||||||
|
echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||||
|
$(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||||
|
fi
|
||||||
|
|
||||||
test: test-python dashboard-check
|
test: test-python dashboard-check
|
||||||
|
|
||||||
test-python:
|
test-python:
|
||||||
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
||||||
$(UV) run pytest -x -q
|
$(UV) run pytest -x -q
|
||||||
|
|
||||||
|
## Benchmark /state/summary revision cache (API must be running on :8000)
|
||||||
|
benchmark-summary-cache:
|
||||||
|
$(UV) run python scripts/benchmark_summary_cache.py
|
||||||
|
|
||||||
## ops-bridge managed tunnels
|
## ops-bridge managed tunnels
|
||||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||||
tunnels-up:
|
tunnels-up:
|
||||||
@@ -249,6 +306,25 @@ fix-consistency:
|
|||||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||||
|
|
||||||
|
## Normalize workplan frontmatter and task status literals in attached repos.
|
||||||
|
## Usage: make normalize-attached-workplans REPO=artifact-store
|
||||||
|
## make normalize-attached-workplans DIRTY=1
|
||||||
|
normalize-attached-workplans:
|
||||||
|
$(UV) run python scripts/normalize_attached_repo_workplans.py \
|
||||||
|
$(if $(REPO),--repo "$(REPO)",) \
|
||||||
|
$(if $(DIRTY),--dirty,) \
|
||||||
|
$(if $(DRY_RUN),--dry-run,)
|
||||||
|
@test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO=<slug> or DIRTY=1"; exit 1)
|
||||||
|
|
||||||
|
## Regenerate AGENTS.md / CLAUDE.md / .claude/rules from templates.
|
||||||
|
## Usage: make update-agent-instructions REPO=artifact-store
|
||||||
|
## make update-agent-instructions DIRTY=1
|
||||||
|
update-agent-instructions:
|
||||||
|
$(UV) run python scripts/update_agent_instruction_files.py \
|
||||||
|
$(if $(REPO),--repo "$(REPO)",) \
|
||||||
|
$(if $(DIRTY),--dirty,)
|
||||||
|
@test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO=<slug> or DIRTY=1"; exit 1)
|
||||||
|
|
||||||
## Reconcile measured token sources against State Hub.
|
## Reconcile measured token sources against State Hub.
|
||||||
## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1]
|
## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1]
|
||||||
token-reconcile:
|
token-reconcile:
|
||||||
@@ -322,5 +398,20 @@ remove-hooks:
|
|||||||
gitea-inventory:
|
gitea-inventory:
|
||||||
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
||||||
|
|
||||||
|
## Register/update one repo from .repo-classification.yaml:
|
||||||
|
## make register-from-classification REPO=state-hub
|
||||||
|
## make register-from-classification PATH=/path/to/repo
|
||||||
|
## Optional: DRY_RUN=1
|
||||||
|
register-from-classification:
|
||||||
|
@test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1)
|
||||||
|
$(UV) run python scripts/register_from_classification.py \
|
||||||
|
$(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \
|
||||||
|
$(if $(DRY_RUN),--dry-run,)
|
||||||
|
|
||||||
|
## Bulk register/update all active repos with accessible local paths
|
||||||
|
register-from-classification-all:
|
||||||
|
$(UV) run python scripts/register_from_classification.py --bulk \
|
||||||
|
$(if $(DRY_RUN),--dry-run,)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
$(COMPOSE) down -v
|
$(COMPOSE) down -v
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -111,7 +111,9 @@ custodian register-project # register cwd as a Custodian project
|
|||||||
| `make db` | Start postgres container |
|
| `make db` | Start postgres container |
|
||||||
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
||||||
| `make migrate` | `alembic upgrade head` |
|
| `make migrate` | `alembic upgrade head` |
|
||||||
| `make seed` | Insert 6 canonical topics |
|
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
|
||||||
|
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
|
||||||
|
| `make register-from-classification-all` | Bulk reclassify all repos with classification files |
|
||||||
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
|
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
|
||||||
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
||||||
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
|
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
|
||||||
@@ -125,28 +127,30 @@ custodian register-project # register cwd as a Custodian project
|
|||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
Five tables in dependency order:
|
Repo-anchored coordination spine (STATE-WP-0065):
|
||||||
|
|
||||||
```
|
```
|
||||||
topics
|
domains (14 market domains: infotech, financials, communication, …)
|
||||||
└── workstreams
|
managed_repos (classification: category, domain, capability_tags, business_stake, …)
|
||||||
└── tasks (self-FK: parent_task_id)
|
└── workplans (repo_id required; topic_id optional legacy tag)
|
||||||
|
└── tasks
|
||||||
└── progress_events
|
└── progress_events
|
||||||
decisions (FK: topic_id, workstream_id — at least one required)
|
topics (optional cross-repo tag; domain_id → market domain)
|
||||||
└── progress_events
|
decisions (FK: topic_id and/or workplan_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Enums
|
Each registered repo carries a committed `.repo-classification.yaml` (canon
|
||||||
|
standard v1.0). Registration and reclassification use
|
||||||
|
`make register-from-classification`.
|
||||||
|
|
||||||
| Enum | Values |
|
### Key enums / vocabularies
|
||||||
|
|
||||||
|
| Field | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `topic_status` | `active` · `paused` · `archived` |
|
| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
|
||||||
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
||||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
|
||||||
| `decision_type` | `made` · `pending` |
|
| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
|
||||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
|
||||||
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
|
|
||||||
|
|
||||||
### Governance constraints encoded in schema
|
### Governance constraints encoded in schema
|
||||||
|
|
||||||
@@ -181,6 +185,14 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Caching:** responses are revision-gated — the API compares cheap per-table
|
||||||
|
`MAX(updated_at)` / `MAX(created_at)` watermarks before rebuilding. Unchanged
|
||||||
|
data returns the cached snapshot (`X-StateHub-Cache: hit-revision`). When core
|
||||||
|
data changes, the last good snapshot may be served immediately while a
|
||||||
|
background refresh runs (`X-StateHub-Cache: stale`). Force a synchronous rebuild
|
||||||
|
with `?refresh=true` or `Cache-Control: no-cache`. Infrastructure probes should
|
||||||
|
use `/state/health`, not `/state/summary`.
|
||||||
|
|
||||||
### Router summary
|
### Router summary
|
||||||
|
|
||||||
| Prefix | Operations |
|
| Prefix | Operations |
|
||||||
@@ -226,9 +238,11 @@ See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster tha
|
|||||||
|
|
||||||
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
|
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
|
||||||
|
|
||||||
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
|
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `create_workplan` · `update_workplan_status` · `register_repo_from_classification`
|
||||||
|
|
||||||
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
**Resources**: `state://summary` · `state://topics` · `state://workplans/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
||||||
|
|
||||||
|
Legacy `workstream_*` tool names remain as aliases — see `mcp_server/TOOLS.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
9
SCOPE.md
9
SCOPE.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## One-Liner
|
## One-Liner
|
||||||
|
|
||||||
State Hub is the local-first coordination service for Custodian workstreams,
|
State Hub is the local-first coordination service for repo-anchored workplans,
|
||||||
tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard
|
tasks, decisions, progress events, repo classification and metadata, MCP
|
||||||
telemetry.
|
tooling, and dashboard telemetry.
|
||||||
|
|
||||||
## In Scope
|
## In Scope
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@ telemetry.
|
|||||||
- PostgreSQL schema and Alembic migrations
|
- PostgreSQL schema and Alembic migrations
|
||||||
- FastMCP server and tool reference
|
- FastMCP server and tool reference
|
||||||
- Observable dashboard
|
- Observable dashboard
|
||||||
- repo registration and consistency synchronization
|
- repo registration (classification-driven) and consistency synchronization
|
||||||
|
- repo classification spine (14 market domains, `.repo-classification.yaml`)
|
||||||
- task-flow engine and flow definitions
|
- task-flow engine and flow definitions
|
||||||
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
|
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
|
||||||
- State Hub tests, operational docs, policies, prompts, and local infra
|
- State Hub tests, operational docs, policies, prompts, and local infra
|
||||||
|
|||||||
290
api/classification.py
Normal file
290
api/classification.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""Repo classification validation for State Hub registration (STATE-WP-0065 P1).
|
||||||
|
|
||||||
|
Loads allowed values from the custodian canon standard and validates classification
|
||||||
|
blocks against controlled vocabularies.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Primary path (sibling checkout); fallback relative to state-hub repo root.
|
||||||
|
_PRIMARY_ALLOWED = Path(
|
||||||
|
"/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"
|
||||||
|
)
|
||||||
|
_FALLBACK_ALLOWED = (
|
||||||
|
Path(__file__).resolve().parent.parent.parent
|
||||||
|
/ "the-custodian"
|
||||||
|
/ "canon"
|
||||||
|
/ "standards"
|
||||||
|
/ "repo-classification.allowed.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClassificationData:
|
||||||
|
"""Normalized classification fields stored on ``managed_repos``."""
|
||||||
|
|
||||||
|
category: str
|
||||||
|
domain: str
|
||||||
|
secondary_domains: list[str] = field(default_factory=list)
|
||||||
|
capability_tags: list[str] = field(default_factory=list)
|
||||||
|
business_stake: list[str] = field(default_factory=list)
|
||||||
|
business_mechanics: list[str] = field(default_factory=list)
|
||||||
|
classified_at: str | None = None
|
||||||
|
classified_by: str | None = None
|
||||||
|
standard_version: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"category": self.category,
|
||||||
|
"domain": self.domain,
|
||||||
|
"secondary_domains": list(self.secondary_domains),
|
||||||
|
"capability_tags": list(self.capability_tags),
|
||||||
|
"business_stake": list(self.business_stake),
|
||||||
|
"business_mechanics": list(self.business_mechanics),
|
||||||
|
"classified_at": self.classified_at,
|
||||||
|
"classified_by": self.classified_by,
|
||||||
|
"standard_version": self.standard_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_block(cls, block: dict) -> ClassificationData:
|
||||||
|
return cls(
|
||||||
|
category=block["category"],
|
||||||
|
domain=block["domain"],
|
||||||
|
secondary_domains=list(block.get("secondary_domains") or []),
|
||||||
|
capability_tags=list(block.get("capability_tags") or []),
|
||||||
|
business_stake=list(block.get("business_stake") or []),
|
||||||
|
business_mechanics=list(block.get("business_mechanics") or []),
|
||||||
|
classified_at=block.get("classified_at"),
|
||||||
|
classified_by=block.get("classified_by"),
|
||||||
|
standard_version=block.get("version") or block.get("standard_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_path() -> Path:
|
||||||
|
if _PRIMARY_ALLOWED.is_file():
|
||||||
|
return _PRIMARY_ALLOWED
|
||||||
|
if _FALLBACK_ALLOWED.is_file():
|
||||||
|
return _FALLBACK_ALLOWED
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"repo-classification.allowed.yaml not found at "
|
||||||
|
f"{_PRIMARY_ALLOWED} or {_FALLBACK_ALLOWED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_allowed_values(path: Path | None = None) -> dict:
|
||||||
|
"""Load the machine-readable allowed-values YAML."""
|
||||||
|
target = path or _allowed_path()
|
||||||
|
with target.open(encoding="utf-8") as fh:
|
||||||
|
return yaml.safe_load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def _known_capability_tags(allowed: dict) -> set[str]:
|
||||||
|
tags: set[str] = set()
|
||||||
|
for fam in (allowed.get("capability_families") or {}).values():
|
||||||
|
tags.update(fam or [])
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def validate_classification(block: dict) -> tuple[list[str], list[str]]:
|
||||||
|
"""Validate a ``repo_classification`` block.
|
||||||
|
|
||||||
|
Returns ``(errors, warnings)``. *block* should be the inner mapping (not the
|
||||||
|
full YAML document with the ``repo_classification`` wrapper).
|
||||||
|
"""
|
||||||
|
allowed = load_allowed_values()
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return (["classification block must be a mapping"], [])
|
||||||
|
|
||||||
|
categories = set(allowed["categories"])
|
||||||
|
domains = set(allowed["domains"])
|
||||||
|
stakes = set(allowed["business_stake"])
|
||||||
|
mechanics = set(allowed["business_mechanics"])
|
||||||
|
guidance = allowed.get("guidance", {})
|
||||||
|
pattern = re.compile(
|
||||||
|
guidance.get("capability_tag_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||||
|
)
|
||||||
|
|
||||||
|
category = block.get("category")
|
||||||
|
if category is None:
|
||||||
|
errors.append("`category` is required")
|
||||||
|
elif category not in categories:
|
||||||
|
errors.append(f"`category` '{category}' not in {sorted(categories)}")
|
||||||
|
|
||||||
|
domain = block.get("domain")
|
||||||
|
if domain is None:
|
||||||
|
errors.append("`domain` is required")
|
||||||
|
elif domain not in domains:
|
||||||
|
errors.append(f"`domain` '{domain}' not in allowed domains")
|
||||||
|
|
||||||
|
secondary = block.get("secondary_domains") or []
|
||||||
|
if not isinstance(secondary, list):
|
||||||
|
errors.append("`secondary_domains` must be a list")
|
||||||
|
secondary = []
|
||||||
|
for d in secondary:
|
||||||
|
if d not in domains:
|
||||||
|
errors.append(f"secondary domain '{d}' not in allowed domains")
|
||||||
|
if d == domain:
|
||||||
|
errors.append(f"secondary domain '{d}' repeats the primary domain")
|
||||||
|
if len(secondary) != len(set(secondary)):
|
||||||
|
errors.append("`secondary_domains` contains duplicates")
|
||||||
|
smax = guidance.get("secondary_domains_max", 3)
|
||||||
|
if len(secondary) > smax:
|
||||||
|
warnings.append(
|
||||||
|
f"{len(secondary)} secondary_domains exceeds recommended max {smax}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = block.get("capability_tags") or []
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
errors.append("`capability_tags` must be a list")
|
||||||
|
tags = []
|
||||||
|
known = _known_capability_tags(allowed)
|
||||||
|
for t in tags:
|
||||||
|
if not isinstance(t, str) or not pattern.match(t):
|
||||||
|
errors.append(f"capability_tag '{t}' is not lowercase kebab-case")
|
||||||
|
elif t not in known:
|
||||||
|
warnings.append(
|
||||||
|
f"capability_tag '{t}' is not a recommended family tag "
|
||||||
|
"(allowed, check for synonym)"
|
||||||
|
)
|
||||||
|
|
||||||
|
stake = block.get("business_stake") or []
|
||||||
|
if not isinstance(stake, list):
|
||||||
|
errors.append("`business_stake` must be a list")
|
||||||
|
stake = []
|
||||||
|
for s in stake:
|
||||||
|
if s not in stakes:
|
||||||
|
errors.append(f"business_stake '{s}' not in {sorted(stakes)}")
|
||||||
|
if stake:
|
||||||
|
lo = guidance.get("business_stake_recommended_min", 2)
|
||||||
|
hi = guidance.get("business_stake_recommended_max", 6)
|
||||||
|
if not (lo <= len(stake) <= hi):
|
||||||
|
warnings.append(
|
||||||
|
f"{len(stake)} business_stake values; {lo}-{hi} recommended"
|
||||||
|
)
|
||||||
|
|
||||||
|
mech = block.get("business_mechanics") or []
|
||||||
|
if not isinstance(mech, list):
|
||||||
|
errors.append("`business_mechanics` must be a list")
|
||||||
|
mech = []
|
||||||
|
for m in mech:
|
||||||
|
if m not in mechanics:
|
||||||
|
errors.append(f"business_mechanics '{m}' not in {sorted(mechanics)}")
|
||||||
|
|
||||||
|
return errors, warnings
|
||||||
|
|
||||||
|
|
||||||
|
CLASSIFICATION_FILENAME = ".repo-classification.yaml"
|
||||||
|
|
||||||
|
# Market-domain slugs (Repo Classification Standard v1.0 §6).
|
||||||
|
MARKET_DOMAIN_SLUGS: frozenset[str] = frozenset({
|
||||||
|
"infotech",
|
||||||
|
"financials",
|
||||||
|
"communication",
|
||||||
|
"consumer",
|
||||||
|
"health",
|
||||||
|
"industrials",
|
||||||
|
"energy",
|
||||||
|
"utilities",
|
||||||
|
"materials",
|
||||||
|
"realestate",
|
||||||
|
"crypto",
|
||||||
|
"agents",
|
||||||
|
"space",
|
||||||
|
"government",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Legacy coordination-domain slugs still found in workplan frontmatter ``domain:``.
|
||||||
|
# Maps to market-domain slugs used by the Hub ``domains`` table post-migration.
|
||||||
|
LEGACY_COORDINATION_TO_MARKET: dict[str, str] = {
|
||||||
|
"custodian": "infotech",
|
||||||
|
"railiance": "financials",
|
||||||
|
"markitect": "communication",
|
||||||
|
"coulomb_social": "communication",
|
||||||
|
"personhood": "government",
|
||||||
|
"foerster_capabilities": "agents",
|
||||||
|
"capabilities": "agents",
|
||||||
|
"canon": "infotech",
|
||||||
|
"citation_evidence": "infotech",
|
||||||
|
"helix_forge": "infotech",
|
||||||
|
"inter_hub": "infotech",
|
||||||
|
"netkingdom": "communication",
|
||||||
|
"stack": "infotech",
|
||||||
|
"vergabe_teilnahme": "government",
|
||||||
|
"whynot": "consumer",
|
||||||
|
"test_domain_v2": "infotech",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_topic_domain_slug(
|
||||||
|
workplan_domain: str,
|
||||||
|
*,
|
||||||
|
repo_market_domain: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Map a workplan frontmatter ``domain`` value to a market-domain slug.
|
||||||
|
|
||||||
|
Workplans may still carry legacy coordination slugs (e.g. ``custodian``)
|
||||||
|
after the spine migration; topic lookup must use the market domain stored
|
||||||
|
on ``domains.slug``.
|
||||||
|
"""
|
||||||
|
domain = (workplan_domain or "").strip()
|
||||||
|
if not domain:
|
||||||
|
return repo_market_domain or ""
|
||||||
|
if domain in MARKET_DOMAIN_SLUGS:
|
||||||
|
return domain
|
||||||
|
mapped = LEGACY_COORDINATION_TO_MARKET.get(domain)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
return repo_market_domain or domain
|
||||||
|
|
||||||
|
|
||||||
|
def load_classification_document(path: Path) -> dict | None:
|
||||||
|
"""Load and return the YAML document, or ``None`` if missing/unreadable."""
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
doc = yaml.safe_load(fh)
|
||||||
|
except (OSError, yaml.YAMLError):
|
||||||
|
return None
|
||||||
|
return doc if isinstance(doc, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_classification_block(doc: dict | None) -> dict | None:
|
||||||
|
"""Return the inner ``repo_classification`` mapping from a loaded document."""
|
||||||
|
if not doc:
|
||||||
|
return None
|
||||||
|
block = doc.get("repo_classification")
|
||||||
|
return block if isinstance(block, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_classification_file(
|
||||||
|
repo_path: Path | str,
|
||||||
|
*,
|
||||||
|
filename: str = CLASSIFICATION_FILENAME,
|
||||||
|
) -> tuple[ClassificationData | None, list[str], list[str]]:
|
||||||
|
"""Load ``.repo-classification.yaml`` from a repo root and validate it.
|
||||||
|
|
||||||
|
Returns ``(data, errors, warnings)``. *data* is ``None`` when the file is
|
||||||
|
missing, unreadable, or has blocking validation errors.
|
||||||
|
"""
|
||||||
|
root = Path(repo_path)
|
||||||
|
doc = load_classification_document(root / filename)
|
||||||
|
block = extract_classification_block(doc)
|
||||||
|
if block is None:
|
||||||
|
if doc is None:
|
||||||
|
return (None, [f"{filename} missing or unreadable"], [])
|
||||||
|
return (None, [f"{filename} has no repo_classification block"], [])
|
||||||
|
|
||||||
|
errors, warnings = validate_classification(block)
|
||||||
|
if errors:
|
||||||
|
return (None, errors, warnings)
|
||||||
|
return (ClassificationData.from_block(block), [], warnings)
|
||||||
1
api/edge/__init__.py
Normal file
1
api/edge/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""State Hub edge relay and durable outbox helpers."""
|
||||||
358
api/edge/outbox.py
Normal file
358
api/edge/outbox.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import stat
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from api.services.write_idempotency import route_class_for
|
||||||
|
|
||||||
|
DEFAULT_OUTBOX_PATH = Path(os.environ.get("STATEHUB_OUTBOX_PATH", "~/.statehub/edge-outbox.sqlite3")).expanduser()
|
||||||
|
MAX_PAYLOAD_BYTES = 64 * 1024
|
||||||
|
SECRET_FIELD_NAMES = {
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"bearer_token",
|
||||||
|
"client_secret",
|
||||||
|
"private_key",
|
||||||
|
"credential",
|
||||||
|
"credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OutboxEnvelope:
|
||||||
|
id: str
|
||||||
|
idempotency_key: str
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
body: dict[str, Any] | list[Any] | None
|
||||||
|
route_class: str
|
||||||
|
source_agent: str | None
|
||||||
|
source_host: str | None
|
||||||
|
repo_slug: str | None
|
||||||
|
session_id: str | None
|
||||||
|
observed_revision: dict[str, Any] | None
|
||||||
|
status: str
|
||||||
|
attempt_count: int
|
||||||
|
next_retry_at: str | None
|
||||||
|
last_error: str | None
|
||||||
|
response_status: int | None
|
||||||
|
response_body: dict[str, Any] | list[Any] | str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
acked_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PayloadRejected(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> str:
|
||||||
|
return datetime.now(tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def default_outbox_path() -> Path:
|
||||||
|
return DEFAULT_OUTBOX_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_payload(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
scrubbed: dict[str, Any] = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
normalized = str(key).lower().replace("-", "_")
|
||||||
|
if normalized in SECRET_FIELD_NAMES:
|
||||||
|
scrubbed[key] = "[redacted]"
|
||||||
|
else:
|
||||||
|
scrubbed[key] = scrub_payload(item)
|
||||||
|
return scrubbed
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [scrub_payload(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads(raw: str | None) -> Any:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxStore:
|
||||||
|
def __init__(self, path: str | Path | None = None) -> None:
|
||||||
|
self.path = Path(path).expanduser() if path is not None else default_outbox_path()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._init_db()
|
||||||
|
self._chmod_private()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS outbox_envelopes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
idempotency_key TEXT NOT NULL UNIQUE,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
body_json TEXT,
|
||||||
|
route_class TEXT NOT NULL,
|
||||||
|
source_agent TEXT,
|
||||||
|
source_host TEXT,
|
||||||
|
repo_slug TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
observed_revision_json TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_retry_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
response_status INTEGER,
|
||||||
|
response_body_json TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
acked_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_status ON outbox_envelopes(status)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_next_retry ON outbox_envelopes(next_retry_at)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _chmod_private(self) -> None:
|
||||||
|
try:
|
||||||
|
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def enqueue(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Any,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
source_agent: str | None = None,
|
||||||
|
source_host: str | None = None,
|
||||||
|
repo_slug: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
observed_revision: dict[str, Any] | None = None,
|
||||||
|
) -> OutboxEnvelope:
|
||||||
|
route_class = route_class_for(method, path)
|
||||||
|
if route_class is None:
|
||||||
|
raise PayloadRejected(f"{method.upper()} {path} is not queueable")
|
||||||
|
scrubbed = scrub_payload(body)
|
||||||
|
encoded = _json_dumps(scrubbed)
|
||||||
|
if encoded is not None and len(encoded.encode("utf-8")) > MAX_PAYLOAD_BYTES:
|
||||||
|
raise PayloadRejected("payload exceeds offline outbox size limit")
|
||||||
|
now = utcnow()
|
||||||
|
envelope_id = str(uuid.uuid4())
|
||||||
|
key = idempotency_key or f"statehub-edge:{envelope_id}"
|
||||||
|
method_upper = method.upper()
|
||||||
|
with self._connect() as conn:
|
||||||
|
if route_class == "replace":
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE outbox_envelopes
|
||||||
|
SET status = 'cancelled', updated_at = ?, last_error = ?
|
||||||
|
WHERE status = 'queued'
|
||||||
|
AND route_class = 'replace'
|
||||||
|
AND method = ?
|
||||||
|
AND path = ?
|
||||||
|
""",
|
||||||
|
(now, f"superseded by {envelope_id}", method_upper, path),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO outbox_envelopes (
|
||||||
|
id, idempotency_key, method, path, body_json, route_class,
|
||||||
|
source_agent, source_host, repo_slug, session_id,
|
||||||
|
observed_revision_json, status, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
envelope_id,
|
||||||
|
key,
|
||||||
|
method_upper,
|
||||||
|
path,
|
||||||
|
encoded,
|
||||||
|
route_class,
|
||||||
|
source_agent,
|
||||||
|
source_host,
|
||||||
|
repo_slug,
|
||||||
|
session_id,
|
||||||
|
_json_dumps(observed_revision),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return self.get(envelope_id)
|
||||||
|
|
||||||
|
def get(self, envelope_id: str) -> OutboxEnvelope:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM outbox_envelopes WHERE id = ?", (envelope_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(envelope_id)
|
||||||
|
return self._row_to_envelope(row)
|
||||||
|
|
||||||
|
def list(self, *, status: str | None = None, limit: int = 100) -> list[OutboxEnvelope]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM outbox_envelopes WHERE status = ? ORDER BY created_at LIMIT ?",
|
||||||
|
(status, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM outbox_envelopes ORDER BY created_at LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_envelope(row) for row in rows]
|
||||||
|
|
||||||
|
def due(self, *, limit: int = 50) -> list[OutboxEnvelope]:
|
||||||
|
now = utcnow()
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM outbox_envelopes
|
||||||
|
WHERE status = 'queued' AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(now, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_envelope(row) for row in rows]
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, Any]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT status, COUNT(*) AS count, MIN(created_at) AS oldest FROM outbox_envelopes GROUP BY status"
|
||||||
|
).fetchall()
|
||||||
|
by_status = {row["status"]: row["count"] for row in rows}
|
||||||
|
oldest_pending = None
|
||||||
|
for row in rows:
|
||||||
|
if row["status"] in {"queued", "sending", "conflict"} and row["oldest"]:
|
||||||
|
oldest_pending = min(filter(None, [oldest_pending, row["oldest"]])) if oldest_pending else row["oldest"]
|
||||||
|
return {
|
||||||
|
"path": str(self.path),
|
||||||
|
"by_status": by_status,
|
||||||
|
"pending_count": sum(by_status.get(status, 0) for status in ("queued", "sending")),
|
||||||
|
"conflict_count": by_status.get("conflict", 0),
|
||||||
|
"oldest_pending_at": oldest_pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
def mark_sending(self, envelope_id: str) -> None:
|
||||||
|
self._update(envelope_id, status="sending", updated_at=utcnow())
|
||||||
|
|
||||||
|
def mark_acked(self, envelope_id: str, *, response_status: int, response_body: Any) -> None:
|
||||||
|
now = utcnow()
|
||||||
|
self._update(
|
||||||
|
envelope_id,
|
||||||
|
status="acked",
|
||||||
|
response_status=response_status,
|
||||||
|
response_body_json=_json_dumps(response_body),
|
||||||
|
updated_at=now,
|
||||||
|
acked_at=now,
|
||||||
|
last_error=None,
|
||||||
|
next_retry_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_conflict(self, envelope_id: str, *, response_status: int, response_body: Any) -> None:
|
||||||
|
self._update(
|
||||||
|
envelope_id,
|
||||||
|
status="conflict",
|
||||||
|
response_status=response_status,
|
||||||
|
response_body_json=_json_dumps(response_body),
|
||||||
|
updated_at=utcnow(),
|
||||||
|
last_error="conflict",
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_dead(self, envelope_id: str, *, error: str, response_status: int | None = None, response_body: Any = None) -> None:
|
||||||
|
self._update(
|
||||||
|
envelope_id,
|
||||||
|
status="dead",
|
||||||
|
response_status=response_status,
|
||||||
|
response_body_json=_json_dumps(response_body),
|
||||||
|
updated_at=utcnow(),
|
||||||
|
last_error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_retry(self, envelope_id: str, *, error: str, attempt_count: int) -> None:
|
||||||
|
delay_seconds = min(3600, 2 ** min(attempt_count, 10))
|
||||||
|
next_retry = datetime.now(tz=timezone.utc) + timedelta(seconds=delay_seconds)
|
||||||
|
self._update(
|
||||||
|
envelope_id,
|
||||||
|
status="queued",
|
||||||
|
attempt_count=attempt_count,
|
||||||
|
next_retry_at=next_retry.isoformat(),
|
||||||
|
updated_at=utcnow(),
|
||||||
|
last_error=error[:500],
|
||||||
|
)
|
||||||
|
|
||||||
|
def retry(self, envelope_id: str) -> None:
|
||||||
|
self._update(envelope_id, status="queued", next_retry_at=None, updated_at=utcnow())
|
||||||
|
|
||||||
|
def cancel(self, envelope_id: str) -> None:
|
||||||
|
self._update(envelope_id, status="cancelled", updated_at=utcnow())
|
||||||
|
|
||||||
|
def export(self, *, status: str | None = None, limit: int = 1000) -> list[dict[str, Any]]:
|
||||||
|
return [envelope.__dict__ for envelope in self.list(status=status, limit=limit)]
|
||||||
|
|
||||||
|
def _update(self, envelope_id: str, **values: Any) -> None:
|
||||||
|
assignments = ", ".join(f"{key} = ?" for key in values)
|
||||||
|
params = list(values.values()) + [envelope_id]
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(f"UPDATE outbox_envelopes SET {assignments} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _row_to_envelope(self, row: sqlite3.Row) -> OutboxEnvelope:
|
||||||
|
return OutboxEnvelope(
|
||||||
|
id=row["id"],
|
||||||
|
idempotency_key=row["idempotency_key"],
|
||||||
|
method=row["method"],
|
||||||
|
path=row["path"],
|
||||||
|
body=_json_loads(row["body_json"]),
|
||||||
|
route_class=row["route_class"],
|
||||||
|
source_agent=row["source_agent"],
|
||||||
|
source_host=row["source_host"],
|
||||||
|
repo_slug=row["repo_slug"],
|
||||||
|
session_id=row["session_id"],
|
||||||
|
observed_revision=_json_loads(row["observed_revision_json"]),
|
||||||
|
status=row["status"],
|
||||||
|
attempt_count=row["attempt_count"],
|
||||||
|
next_retry_at=row["next_retry_at"],
|
||||||
|
last_error=row["last_error"],
|
||||||
|
response_status=row["response_status"],
|
||||||
|
response_body=_json_loads(row["response_body_json"]),
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
acked_at=row["acked_at"],
|
||||||
|
)
|
||||||
206
api/edge/relay.py
Normal file
206
api/edge/relay.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from api.edge.outbox import OutboxEnvelope, OutboxStore, PayloadRejected, default_outbox_path
|
||||||
|
from api.services.write_idempotency import route_class_for
|
||||||
|
|
||||||
|
HOP_BY_HOP_HEADERS = {
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"content-encoding",
|
||||||
|
"content-length",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_response_headers(headers: httpx.Headers) -> dict[str, str]:
|
||||||
|
return {key: value for key, value in headers.items() if key.lower() not in HOP_BY_HOP_HEADERS}
|
||||||
|
|
||||||
|
|
||||||
|
def _body_summary(response: httpx.Response) -> Any:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"text": response.text[:500]}
|
||||||
|
|
||||||
|
|
||||||
|
def queued_receipt(envelope: OutboxEnvelope, upstream_error: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"queued": True,
|
||||||
|
"outbox_id": envelope.id,
|
||||||
|
"idempotency_key": envelope.idempotency_key,
|
||||||
|
"upstream": "unreachable",
|
||||||
|
"upstream_error": upstream_error,
|
||||||
|
"route_class": envelope.route_class,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def replay_pending(
|
||||||
|
store: OutboxStore,
|
||||||
|
*,
|
||||||
|
upstream_url: str,
|
||||||
|
limit: int = 50,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
counts = {"sent": 0, "acked": 0, "conflict": 0, "retry": 0, "dead": 0}
|
||||||
|
async with httpx.AsyncClient(base_url=upstream_url.rstrip("/"), timeout=timeout) as client:
|
||||||
|
for envelope in store.due(limit=limit):
|
||||||
|
counts["sent"] += 1
|
||||||
|
store.mark_sending(envelope.id)
|
||||||
|
try:
|
||||||
|
response = await client.request(
|
||||||
|
envelope.method,
|
||||||
|
envelope.path,
|
||||||
|
json=envelope.body,
|
||||||
|
headers={
|
||||||
|
"Idempotency-Key": envelope.idempotency_key,
|
||||||
|
"X-StateHub-Source-Agent": envelope.source_agent or "statehub-edge",
|
||||||
|
"X-StateHub-Source-Host": envelope.source_host or socket.gethostname(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
counts["retry"] += 1
|
||||||
|
store.mark_retry(envelope.id, error=str(exc), attempt_count=envelope.attempt_count + 1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
response_body = _body_summary(response)
|
||||||
|
if response.status_code == 409:
|
||||||
|
counts["conflict"] += 1
|
||||||
|
store.mark_conflict(envelope.id, response_status=response.status_code, response_body=response_body)
|
||||||
|
elif 200 <= response.status_code < 300:
|
||||||
|
counts["acked"] += 1
|
||||||
|
store.mark_acked(envelope.id, response_status=response.status_code, response_body=response_body)
|
||||||
|
elif response.status_code >= 500:
|
||||||
|
counts["retry"] += 1
|
||||||
|
store.mark_retry(
|
||||||
|
envelope.id,
|
||||||
|
error=f"HTTP {response.status_code}: {response.text[:300]}",
|
||||||
|
attempt_count=envelope.attempt_count + 1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
counts["dead"] += 1
|
||||||
|
store.mark_dead(
|
||||||
|
envelope.id,
|
||||||
|
error=f"HTTP {response.status_code}: not retryable",
|
||||||
|
response_status=response.status_code,
|
||||||
|
response_body=response_body,
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(
|
||||||
|
*,
|
||||||
|
upstream_url: str | None = None,
|
||||||
|
outbox_path: str | None = None,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
) -> FastAPI:
|
||||||
|
upstream = (upstream_url or os.environ.get("STATEHUB_UPSTREAM_URL") or os.environ.get("API_BASE") or "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
store_path = outbox_path or default_outbox_path()
|
||||||
|
store_instance: OutboxStore | None = None
|
||||||
|
|
||||||
|
def get_store() -> OutboxStore:
|
||||||
|
nonlocal store_instance
|
||||||
|
if store_instance is None:
|
||||||
|
store_instance = OutboxStore(store_path)
|
||||||
|
return store_instance
|
||||||
|
|
||||||
|
app = FastAPI(title="State Hub Edge Relay", version="0.1.0")
|
||||||
|
|
||||||
|
@app.get("/edge/health")
|
||||||
|
async def edge_health() -> dict[str, Any]:
|
||||||
|
reachable = False
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(base_url=upstream, timeout=2.0) as client:
|
||||||
|
response = await client.get("/state/health")
|
||||||
|
reachable = response.status_code < 500
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"upstream": upstream,
|
||||||
|
"upstream_reachable": reachable,
|
||||||
|
"upstream_error": error,
|
||||||
|
"outbox": get_store().summary(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/edge/replay")
|
||||||
|
async def edge_replay(limit: int = 50) -> dict[str, int]:
|
||||||
|
return await replay_pending(get_store(), upstream_url=upstream, limit=limit, timeout=timeout)
|
||||||
|
|
||||||
|
@app.api_route("/{path:path}", methods=["GET", "POST", "PATCH", "PUT", "DELETE"])
|
||||||
|
async def proxy(path: str, request: Request) -> Response:
|
||||||
|
api_path = "/" + path
|
||||||
|
body: Any = None
|
||||||
|
if request.method in {"POST", "PATCH", "PUT"}:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if idempotency_key := request.headers.get("idempotency-key"):
|
||||||
|
headers["Idempotency-Key"] = idempotency_key
|
||||||
|
if request.headers.get("content-type"):
|
||||||
|
headers["Content-Type"] = request.headers["content-type"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(base_url=upstream, timeout=timeout) as client:
|
||||||
|
response = await client.request(
|
||||||
|
request.method,
|
||||||
|
api_path,
|
||||||
|
params=request.query_params,
|
||||||
|
json=body if body is not None else None,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=_safe_response_headers(response.headers),
|
||||||
|
media_type=response.headers.get("content-type"),
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
route_class = route_class_for(request.method, api_path)
|
||||||
|
if route_class is None or request.method not in {"POST", "PATCH"}:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={
|
||||||
|
"error": "upstream unreachable and route is not queueable",
|
||||||
|
"method": request.method,
|
||||||
|
"path": api_path,
|
||||||
|
"upstream": upstream,
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
envelope = get_store().enqueue(
|
||||||
|
method=request.method,
|
||||||
|
path=api_path,
|
||||||
|
body=body,
|
||||||
|
idempotency_key=request.headers.get("idempotency-key"),
|
||||||
|
source_agent=request.headers.get("x-statehub-source-agent"),
|
||||||
|
source_host=request.headers.get("x-statehub-source-host") or socket.gethostname(),
|
||||||
|
repo_slug=request.headers.get("x-statehub-repo-slug"),
|
||||||
|
session_id=request.headers.get("x-statehub-session-id"),
|
||||||
|
observed_revision=None,
|
||||||
|
)
|
||||||
|
except PayloadRejected as reject:
|
||||||
|
return JSONResponse(status_code=422, content={"error": str(reject)})
|
||||||
|
return JSONResponse(status_code=202, content=queued_receipt(envelope, str(exc)))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
13
api/main.py
13
api/main.py
@@ -11,12 +11,14 @@ from starlette.responses import Response as StarletteResponse
|
|||||||
|
|
||||||
from api.database import engine
|
from api.database import engine
|
||||||
from api.events import shutdown_publisher
|
from api.events import shutdown_publisher
|
||||||
|
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
||||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc
|
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||||
from api.routers import token_events
|
from api.routers import token_events
|
||||||
from api.routers import interface_changes
|
from api.routers import interface_changes
|
||||||
from api.routers import flows
|
from api.routers import flows
|
||||||
from api.routers import recently_on_scope
|
from api.routers import recently_on_scope
|
||||||
|
from api.routers import consistency_sweep
|
||||||
from api.routers import reconciliation
|
from api.routers import reconciliation
|
||||||
from api.routers import execution
|
from api.routers import execution
|
||||||
from api.routers import fabric
|
from api.routers import fabric
|
||||||
@@ -90,18 +92,20 @@ _default_dashboard_origins = [
|
|||||||
_cors_env = os.getenv("CORS_ORIGINS", ",".join(_default_dashboard_origins))
|
_cors_env = os.getenv("CORS_ORIGINS", ",".join(_default_dashboard_origins))
|
||||||
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||||
|
|
||||||
|
app.add_middleware(WriteIdempotencyMiddleware)
|
||||||
app.add_middleware(ETagMiddleware)
|
app.add_middleware(ETagMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=_cors_origins,
|
allow_origins=_cors_origins,
|
||||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
||||||
allow_headers=["Content-Type", "If-None-Match"],
|
allow_headers=["Content-Type", "If-None-Match", "Idempotency-Key", "X-StateHub-Source-Agent", "X-StateHub-Source-Host"],
|
||||||
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache"],
|
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache", "X-StateHub-Idempotency-Replay"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(domains.router)
|
app.include_router(domains.router)
|
||||||
app.include_router(recently_on_scope.hourly_router)
|
app.include_router(recently_on_scope.hourly_router)
|
||||||
app.include_router(recently_on_scope.router)
|
app.include_router(recently_on_scope.router)
|
||||||
|
app.include_router(consistency_sweep.router)
|
||||||
app.include_router(repos.router)
|
app.include_router(repos.router)
|
||||||
app.include_router(topics.router)
|
app.include_router(topics.router)
|
||||||
app.include_router(workstreams.router)
|
app.include_router(workstreams.router)
|
||||||
@@ -120,6 +124,7 @@ app.include_router(sbom.router)
|
|||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
app.include_router(capability_requests.router)
|
app.include_router(capability_requests.router)
|
||||||
app.include_router(tpsc.router)
|
app.include_router(tpsc.router)
|
||||||
|
app.include_router(services.router)
|
||||||
app.include_router(token_events.router)
|
app.include_router(token_events.router)
|
||||||
app.include_router(interface_changes.router)
|
app.include_router(interface_changes.router)
|
||||||
app.include_router(flows.router)
|
app.include_router(flows.router)
|
||||||
@@ -133,4 +138,4 @@ app.include_router(policy.router)
|
|||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
async def root():
|
async def root():
|
||||||
return {"service": "state-hub", "docs": "/docs"}
|
return {"service": "dev-hub", "docs": "/docs"}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from api.models.domain_goal import DomainGoal, DomainGoalStatus
|
|||||||
from api.models.topic import Topic, TopicStatus
|
from api.models.topic import Topic, TopicStatus
|
||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||||
|
from api.models.workplan import Workplan
|
||||||
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.models.workstream import Workstream
|
from api.models.workstream import Workstream
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workstream_dependency import WorkstreamDependency
|
||||||
from api.models.task import Task, TaskStatus, TaskPriority
|
from api.models.task import Task, TaskStatus, TaskPriority
|
||||||
@@ -18,12 +20,20 @@ from api.models.agent_message import AgentMessage
|
|||||||
from api.models.capability_catalog import CapabilityCatalog
|
from api.models.capability_catalog import CapabilityCatalog
|
||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||||
|
from api.models.service_catalog import (
|
||||||
|
ServiceCatalog,
|
||||||
|
ServiceThirdParty,
|
||||||
|
ServiceFirstParty,
|
||||||
|
ServiceCloud,
|
||||||
|
ServiceSelfHosted,
|
||||||
|
)
|
||||||
from api.models.doi_cache import DOICache
|
from api.models.doi_cache import DOICache
|
||||||
from api.models.token_event import TokenEvent
|
from api.models.token_event import TokenEvent
|
||||||
from api.models.interface_change import InterfaceChange
|
from api.models.interface_change import InterfaceChange
|
||||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||||
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
||||||
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
||||||
|
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -32,6 +42,8 @@ __all__ = [
|
|||||||
"Topic", "TopicStatus",
|
"Topic", "TopicStatus",
|
||||||
"ManagedRepo",
|
"ManagedRepo",
|
||||||
"RepoGoal", "RepoGoalStatus",
|
"RepoGoal", "RepoGoalStatus",
|
||||||
|
"Workplan",
|
||||||
|
"WorkplanDependency",
|
||||||
"Workstream",
|
"Workstream",
|
||||||
"WorkstreamDependency",
|
"WorkstreamDependency",
|
||||||
"Task", "TaskStatus", "TaskPriority",
|
"Task", "TaskStatus", "TaskPriority",
|
||||||
@@ -46,10 +58,13 @@ __all__ = [
|
|||||||
"CapabilityCatalog",
|
"CapabilityCatalog",
|
||||||
"CapabilityRequest",
|
"CapabilityRequest",
|
||||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||||
|
"ServiceCatalog", "ServiceThirdParty", "ServiceFirstParty",
|
||||||
|
"ServiceCloud", "ServiceSelfHosted",
|
||||||
"DOICache",
|
"DOICache",
|
||||||
"TokenEvent",
|
"TokenEvent",
|
||||||
"InterfaceChange",
|
"InterfaceChange",
|
||||||
"WorkplanLaunchRequest",
|
"WorkplanLaunchRequest",
|
||||||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||||
]
|
"WriteIdempotencyKey",
|
||||||
|
]
|
||||||
@@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
@@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ class Contribution(Base, TimestampMixin):
|
|||||||
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
||||||
@@ -62,5 +62,5 @@ class Contribution(Base, TimestampMixin):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class Decision(Base, TimestampMixin):
|
|||||||
__tablename__ = "decisions"
|
__tablename__ = "decisions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
|
||||||
name="ck_decisions_topic_or_workstream",
|
name="ck_decisions_topic_or_workplan",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -57,7 +57,7 @@ class Decision(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
|
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821
|
||||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ class ExtensionPoint(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain_slug(self) -> str:
|
def domain_slug(self) -> str:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
from sqlalchemy import Date, DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from api.models.base import Base, TimestampMixin, new_uuid
|
from api.models.base import Base, TimestampMixin, new_uuid
|
||||||
@@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin):
|
|||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
|
classified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
"Domain", back_populates="repos", lazy="selectin"
|
"Domain", back_populates="repos", lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ class ProgressEvent(Base):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
@@ -38,6 +38,6 @@ class ProgressEvent(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
|
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821
|
||||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ class RepoGoal(Base, TimestampMixin):
|
|||||||
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
||||||
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
||||||
)
|
)
|
||||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||||
"Workstream", back_populates="repo_goal", lazy="selectin"
|
"Workplan", back_populates="repo_goal", lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
121
api/models/service_catalog.py
Normal file
121
api/models/service_catalog.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Two-dimension service catalog (STATE-WP-0062).
|
||||||
|
|
||||||
|
Every service is classified along two orthogonal dimensions:
|
||||||
|
|
||||||
|
- hosting_type: self_hosted (coulomb operates it) | cloud_hosted (consumed)
|
||||||
|
- development_type: first_party (coulomb develops it) | third_party (external)
|
||||||
|
|
||||||
|
Common fields live in ``ServiceCatalog``; dimension-specific data composes via
|
||||||
|
1:1 extension tables (``service_id`` is both PK and FK), so a self-hosted
|
||||||
|
first-party service carries the self-hosted *and* first-party extensions without
|
||||||
|
needing a bespoke per-class shape.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from api.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCatalog(Base):
|
||||||
|
__tablename__ = "service_catalog"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
owner_or_provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# status: active | deprecated
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
|
||||||
|
# hosting_type: self_hosted | cloud_hosted
|
||||||
|
hosting_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
# development_type: first_party | third_party
|
||||||
|
development_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
# Service DoM Level (1=Operable, 2=Observable, 3=Mature); NULL = unassessed
|
||||||
|
maturity_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
third_party: Mapped["ServiceThirdParty | None"] = relationship(
|
||||||
|
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||||
|
first_party: Mapped["ServiceFirstParty | None"] = relationship(
|
||||||
|
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||||
|
cloud: Mapped["ServiceCloud | None"] = relationship(
|
||||||
|
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||||
|
self_hosted: Mapped["ServiceSelfHosted | None"] = relationship(
|
||||||
|
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceThirdParty(Base):
|
||||||
|
"""Extension for development_type = third_party (coulomb is not dev-responsible)."""
|
||||||
|
__tablename__ = "service_third_party"
|
||||||
|
|
||||||
|
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
# pricing_model: free | paid | freemium | usage_based | unknown
|
||||||
|
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
|
||||||
|
upstream_packages: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
upstream_contacts: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
support_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
license: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
service: Mapped["ServiceCatalog"] = relationship(back_populates="third_party")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFirstParty(Base):
|
||||||
|
"""Extension for development_type = first_party (coulomb develops it)."""
|
||||||
|
__tablename__ = "service_first_party"
|
||||||
|
|
||||||
|
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
owning_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
service: Mapped["ServiceCatalog"] = relationship(back_populates="first_party")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCloud(Base):
|
||||||
|
"""Extension for hosting_type = cloud_hosted (data is processed off coulomb infra).
|
||||||
|
|
||||||
|
Holds the data-processor concerns that were the heart of the old TPSC record;
|
||||||
|
they apply whenever data leaves coulomb infra, independent of who built it.
|
||||||
|
"""
|
||||||
|
__tablename__ = "service_cloud"
|
||||||
|
|
||||||
|
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
# gdpr_maturity (CNIL/IAPP CMMI-aligned):
|
||||||
|
# unknown | non_compliant | initial | developing | defined | managed | certified
|
||||||
|
gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True)
|
||||||
|
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||||
|
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
service: Mapped["ServiceCatalog"] = relationship(back_populates="cloud")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSelfHosted(Base):
|
||||||
|
"""Extension for hosting_type = self_hosted (coulomb operates the service)."""
|
||||||
|
__tablename__ = "service_self_hosted"
|
||||||
|
|
||||||
|
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
# three-helix instance / host the service runs on
|
||||||
|
helix_instance: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
host_node: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
deployment_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
runbook_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# upstream OSS project when the self-hosted service is third-party software
|
||||||
|
upstream_oss_project: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
|
service: Mapped["ServiceCatalog"] = relationship(back_populates="self_hosted")
|
||||||
@@ -30,8 +30,8 @@ class Task(Base, TimestampMixin):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -50,7 +50,7 @@ class Task(Base, TimestampMixin):
|
|||||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821
|
||||||
subtasks: Mapped[list["Task"]] = relationship(
|
subtasks: Mapped[list["Task"]] = relationship(
|
||||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ class TechnicalDebt(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
notes: Mapped[list["TDNote"]] = relationship(
|
notes: Mapped[list["TDNote"]] = relationship(
|
||||||
"TDNote", back_populates="td", lazy="selectin",
|
"TDNote", back_populates="td", lazy="selectin",
|
||||||
order_by="TDNote.created_at",
|
order_by="TDNote.created_at",
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ class TokenEvent(Base):
|
|||||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
@@ -75,5 +75,5 @@ class TokenEvent(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ class Topic(Base, TimestampMixin):
|
|||||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
"Domain", back_populates="topics", lazy="selectin"
|
"Domain", back_populates="topics", lazy="selectin"
|
||||||
)
|
)
|
||||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||||
"Workstream", back_populates="topic", lazy="selectin"
|
"Workplan", back_populates="topic", lazy="selectin"
|
||||||
)
|
)
|
||||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||||
"Decision", back_populates="topic", lazy="selectin"
|
"Decision", back_populates="topic", lazy="selectin"
|
||||||
|
|||||||
70
api/models/workplan.py
Normal file
70
api/models/workplan.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from api.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Workplan(Base, TimestampMixin):
|
||||||
|
__tablename__ = "workplans"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="active", server_default="active"
|
||||||
|
)
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||||
|
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
execution_state: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||||
|
)
|
||||||
|
launch_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||||
|
)
|
||||||
|
concurrency_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="sequential", server_default="sequential", index=True
|
||||||
|
)
|
||||||
|
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="workplans") # noqa: F821
|
||||||
|
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||||
|
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workplans", lazy="selectin") # noqa: F821
|
||||||
|
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||||
|
"Task", back_populates="workplan", lazy="selectin"
|
||||||
|
)
|
||||||
|
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||||
|
"Decision", back_populates="workplan", lazy="selectin"
|
||||||
|
)
|
||||||
|
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||||
|
"ProgressEvent", back_populates="workplan", lazy="selectin"
|
||||||
|
)
|
||||||
|
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
|
||||||
|
"WorkplanLaunchRequest", back_populates="workplan", lazy="selectin"
|
||||||
|
)
|
||||||
75
api/models/workplan_dependency.py
Normal file
75
api/models/workplan_dependency.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from api.models.base import Base, TimestampMixin, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanDependency(Base, TimestampMixin):
|
||||||
|
"""Directed dependency edge: `from_workplan` depends on a workplan or task.
|
||||||
|
|
||||||
|
Semantics: the target must reach a satisfactory state before `from_workplan`
|
||||||
|
can fully proceed. Hard deletes are intentional —
|
||||||
|
removing an edge removes a constraint, not information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "workplan_dependencies"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(to_workplan_id IS NOT NULL AND to_task_id IS NULL) "
|
||||||
|
"OR (to_workplan_id IS NULL AND to_task_id IS NOT NULL)",
|
||||||
|
name="ck_wp_dep_exactly_one_target",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_wp_dep_workplan_target",
|
||||||
|
"from_workplan_id",
|
||||||
|
"to_workplan_id",
|
||||||
|
"relationship_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("to_workplan_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_wp_dep_task_target",
|
||||||
|
"from_workplan_id",
|
||||||
|
"to_task_id",
|
||||||
|
"relationship_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("to_task_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
|
)
|
||||||
|
from_workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
relationship_type: Mapped[str] = mapped_column(
|
||||||
|
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
from_workplan: Mapped["Workplan"] = relationship( # noqa: F821
|
||||||
|
"Workplan", foreign_keys=[from_workplan_id]
|
||||||
|
)
|
||||||
|
to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821
|
||||||
|
"Workplan", foreign_keys=[to_workplan_id]
|
||||||
|
)
|
||||||
|
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||||
@@ -13,9 +13,9 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||||
)
|
)
|
||||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
@@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
|
|
||||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="launch_requests") # noqa: F821
|
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821
|
||||||
|
|||||||
@@ -1,70 +1,6 @@
|
|||||||
import uuid
|
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
|
||||||
from datetime import date, datetime
|
from api.models.workplan import Workplan
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
Workstream = Workplan
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from api.models.base import Base, TimestampMixin, new_uuid
|
__all__ = ["Workstream", "Workplan"]
|
||||||
|
|
||||||
|
|
||||||
class Workstream(Base, TimestampMixin):
|
|
||||||
__tablename__ = "workstreams"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
|
||||||
)
|
|
||||||
topic_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
|
|
||||||
)
|
|
||||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="active", server_default="active"
|
|
||||||
)
|
|
||||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
|
||||||
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
|
||||||
execution_state: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
|
||||||
)
|
|
||||||
launch_mode: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
|
||||||
)
|
|
||||||
concurrency_mode: Mapped[str] = mapped_column(
|
|
||||||
String(20), nullable=False, default="sequential", server_default="sequential", index=True
|
|
||||||
)
|
|
||||||
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
|
||||||
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
|
||||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
|
||||||
|
|
||||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
|
|
||||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
|
||||||
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821
|
|
||||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
|
||||||
"Task", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
|
||||||
"Decision", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
|
||||||
"ProgressEvent", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
|
|
||||||
"WorkplanLaunchRequest", back_populates="workstream", lazy="selectin"
|
|
||||||
)
|
|
||||||
@@ -1,75 +1,6 @@
|
|||||||
import uuid
|
"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``."""
|
||||||
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
WorkstreamDependency = WorkplanDependency
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from api.models.base import Base, TimestampMixin, new_uuid
|
__all__ = ["WorkstreamDependency", "WorkplanDependency"]
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamDependency(Base, TimestampMixin):
|
|
||||||
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
|
|
||||||
|
|
||||||
Semantics: the target must reach a satisfactory state before `from_workstream`
|
|
||||||
can fully proceed. Hard deletes are intentional —
|
|
||||||
removing an edge removes a constraint, not information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "workstream_dependencies"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint(
|
|
||||||
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
|
|
||||||
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
|
|
||||||
name="ck_ws_dep_exactly_one_target",
|
|
||||||
),
|
|
||||||
Index(
|
|
||||||
"uq_ws_dep_workstream_target",
|
|
||||||
"from_workstream_id",
|
|
||||||
"to_workstream_id",
|
|
||||||
"relationship_type",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=text("to_workstream_id IS NOT NULL"),
|
|
||||||
),
|
|
||||||
Index(
|
|
||||||
"uq_ws_dep_task_target",
|
|
||||||
"from_workstream_id",
|
|
||||||
"to_task_id",
|
|
||||||
"relationship_type",
|
|
||||||
unique=True,
|
|
||||||
postgresql_where=text("to_task_id IS NOT NULL"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
|
||||||
)
|
|
||||||
from_workstream_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
relationship_type: Mapped[str] = mapped_column(
|
|
||||||
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
|
||||||
)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
|
||||||
"Workstream", foreign_keys=[from_workstream_id]
|
|
||||||
)
|
|
||||||
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
|
|
||||||
"Workstream", foreign_keys=[to_workstream_id]
|
|
||||||
)
|
|
||||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
|
||||||
32
api/models/write_idempotency_key.py
Normal file
32
api/models/write_idempotency_key.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from api.models.base import Base, new_uuid
|
||||||
|
|
||||||
|
|
||||||
|
class WriteIdempotencyKey(Base):
|
||||||
|
__tablename__ = "write_idempotency_keys"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("key", name="uq_write_idempotency_keys_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||||
|
key: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||||
|
method: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
route_class: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
response_status: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
response_body: Mapped[Any] = mapped_column(JSONB, nullable=True)
|
||||||
|
source_host: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
source_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import HTTPException
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -26,60 +25,58 @@ from api.schemas.capability_request import (
|
|||||||
from hub_core.routers.capabilities import (
|
from hub_core.routers.capabilities import (
|
||||||
create_capability_catalog_router,
|
create_capability_catalog_router,
|
||||||
create_capability_request_read_router,
|
create_capability_request_read_router,
|
||||||
)
|
create_capability_request_write_router,
|
||||||
|
|
||||||
|
|
||||||
router = create_capability_catalog_router(
|
|
||||||
get_session,
|
|
||||||
domain_model=Domain,
|
|
||||||
repo_model=ManagedRepo,
|
|
||||||
catalog_model=CapabilityCatalog,
|
|
||||||
)
|
|
||||||
router.include_router(
|
|
||||||
create_capability_request_read_router(
|
|
||||||
get_session,
|
|
||||||
domain_model=Domain,
|
|
||||||
request_model=CapabilityRequest,
|
|
||||||
request_read_schema=CapabilityRequestRead,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Capability Request endpoints
|
# Write-router callbacks
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
async def _route_capability(
|
||||||
async def create_request(
|
session: AsyncSession,
|
||||||
body: CapabilityRequestCreate,
|
body: CapabilityRequestCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
|
||||||
) -> CapabilityRequest:
|
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
|
||||||
req_domain = await _resolve_domain(body.requesting_domain, session)
|
session,
|
||||||
|
body.capability_type,
|
||||||
# Route to provider
|
body.title,
|
||||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
|
body.description or "",
|
||||||
session, body.capability_type, body.title, body.description or ""
|
|
||||||
)
|
)
|
||||||
|
return fulfilling_domain_id, catalog_entry_id, routing_note
|
||||||
|
|
||||||
req = CapabilityRequest(
|
|
||||||
|
def _build_capability_request(
|
||||||
|
body: CapabilityRequestCreate,
|
||||||
|
requesting_domain: Domain,
|
||||||
|
fulfilling_domain_id: uuid.UUID | None,
|
||||||
|
catalog_entry_id: uuid.UUID | None,
|
||||||
|
routing_note: str | None,
|
||||||
|
) -> CapabilityRequest:
|
||||||
|
return CapabilityRequest(
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
capability_type=body.capability_type,
|
capability_type=body.capability_type,
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
requesting_domain_id=req_domain.id,
|
requesting_domain_id=requesting_domain.id,
|
||||||
requesting_agent=body.requesting_agent,
|
requesting_agent=body.requesting_agent,
|
||||||
requesting_workstream_id=body.requesting_workstream_id,
|
requesting_workplan_id=body.requesting_workplan_id,
|
||||||
blocking_task_id=body.blocking_task_id,
|
blocking_task_id=body.blocking_task_id,
|
||||||
fulfilling_domain_id=fulfilling_domain_id,
|
fulfilling_domain_id=fulfilling_domain_id,
|
||||||
catalog_entry_id=catalog_entry_id,
|
catalog_entry_id=catalog_entry_id,
|
||||||
routing_note=routing_note,
|
routing_note=routing_note,
|
||||||
)
|
)
|
||||||
session.add(req)
|
|
||||||
await session.flush() # get req.id before creating notification
|
|
||||||
|
|
||||||
# Auto-notify
|
|
||||||
if fulfilling_domain_id:
|
async def _notify_on_create(
|
||||||
ful_domain = await session.get(Domain, fulfilling_domain_id)
|
session: AsyncSession,
|
||||||
|
req: CapabilityRequest,
|
||||||
|
body: CapabilityRequestCreate,
|
||||||
|
) -> None:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if req.fulfilling_domain_id:
|
||||||
|
ful_domain = await session.get(Domain, req.fulfilling_domain_id)
|
||||||
to_agent = ful_domain.slug if ful_domain else "broadcast"
|
to_agent = ful_domain.slug if ful_domain else "broadcast"
|
||||||
else:
|
else:
|
||||||
to_agent = "broadcast"
|
to_agent = "broadcast"
|
||||||
@@ -98,29 +95,16 @@ async def create_request(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(req)
|
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
|
||||||
return req
|
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||||
|
|
||||||
|
|
||||||
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
async def _notify_on_accept(
|
||||||
async def accept_request(
|
session: AsyncSession,
|
||||||
request_id: uuid.UUID,
|
req: CapabilityRequest,
|
||||||
body: CapabilityRequestAccept,
|
body: CapabilityRequestAccept,
|
||||||
session: AsyncSession = Depends(get_session),
|
) -> None:
|
||||||
) -> CapabilityRequest:
|
|
||||||
req = await _get_request_or_404(request_id, session)
|
|
||||||
_check_transition(req.status, "accepted")
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
req.status = "accepted"
|
|
||||||
req.fulfilling_agent = body.fulfilling_agent
|
|
||||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
|
||||||
req.accepted_at = now
|
|
||||||
|
|
||||||
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
|
||||||
# (The agent can also PATCH it later if needed)
|
|
||||||
|
|
||||||
_add_notification(
|
_add_notification(
|
||||||
session,
|
session,
|
||||||
from_agent=body.fulfilling_agent,
|
from_agent=body.fulfilling_agent,
|
||||||
@@ -129,30 +113,14 @@ async def accept_request(
|
|||||||
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(req)
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
async def _on_status_change(
|
||||||
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
session: AsyncSession,
|
||||||
async def patch_request_status(
|
req: CapabilityRequest,
|
||||||
request_id: uuid.UUID,
|
|
||||||
body: CapabilityRequestStatusPatch,
|
body: CapabilityRequestStatusPatch,
|
||||||
session: AsyncSession = Depends(get_session),
|
now: datetime,
|
||||||
) -> CapabilityRequest:
|
) -> None:
|
||||||
req = await _get_request_or_404(request_id, session)
|
|
||||||
_check_transition(req.status, body.status)
|
|
||||||
|
|
||||||
req.status = body.status
|
|
||||||
if body.note:
|
|
||||||
req.resolution_note = body.note
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
|
|
||||||
# Status-specific side effects
|
|
||||||
if body.status == "completed":
|
if body.status == "completed":
|
||||||
req.completed_at = now
|
|
||||||
# Auto-unblock the blocking task
|
|
||||||
if req.blocking_task_id:
|
if req.blocking_task_id:
|
||||||
task = await session.get(Task, req.blocking_task_id)
|
task = await session.get(Task, req.blocking_task_id)
|
||||||
if task and task.status == "wait":
|
if task and task.status == "wait":
|
||||||
@@ -200,23 +168,12 @@ async def patch_request_status(
|
|||||||
body=f"Work on capability **{req.title}** is now in progress.",
|
body=f"Work on capability **{req.title}** is now in progress.",
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(req)
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
async def _apply_capability_patch(
|
||||||
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
session: AsyncSession,
|
||||||
async def patch_request(
|
req: CapabilityRequest,
|
||||||
request_id: uuid.UUID,
|
|
||||||
body: CapabilityRequestPatch,
|
body: CapabilityRequestPatch,
|
||||||
session: AsyncSession = Depends(get_session),
|
) -> bool:
|
||||||
) -> CapabilityRequest:
|
|
||||||
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
|
||||||
priority, blocking_task_id, fulfilling_workstream_id.
|
|
||||||
Only fields present in the request body (non-None) are updated.
|
|
||||||
"""
|
|
||||||
req = await _get_request_or_404(request_id, session)
|
|
||||||
|
|
||||||
corrections: list[str] = []
|
corrections: list[str] = []
|
||||||
|
|
||||||
if body.catalog_entry_id is not None:
|
if body.catalog_entry_id is not None:
|
||||||
@@ -225,8 +182,6 @@ async def patch_request(
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||||
req.catalog_entry_id = entry.id
|
req.catalog_entry_id = entry.id
|
||||||
# Re-derive fulfilling domain from catalog entry
|
|
||||||
old_domain_id = req.fulfilling_domain_id
|
|
||||||
req.fulfilling_domain_id = entry.domain_id
|
req.fulfilling_domain_id = entry.domain_id
|
||||||
corrections.append(
|
corrections.append(
|
||||||
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
||||||
@@ -241,49 +196,30 @@ async def patch_request(
|
|||||||
req.blocking_task_id = body.blocking_task_id
|
req.blocking_task_id = body.blocking_task_id
|
||||||
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
||||||
|
|
||||||
if body.fulfilling_workstream_id is not None:
|
if body.fulfilling_workplan_id is not None:
|
||||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||||
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
|
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
|
||||||
|
|
||||||
if not corrections:
|
if not corrections:
|
||||||
return req # no-op
|
return False
|
||||||
|
|
||||||
correction_note = "hub correction: " + "; ".join(corrections)
|
correction_note = "hub correction: " + "; ".join(corrections)
|
||||||
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
||||||
|
return True
|
||||||
await session.commit()
|
|
||||||
await session.refresh(req)
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
async def _notify_on_dispute(
|
||||||
# Dispute endpoints
|
session: AsyncSession,
|
||||||
# ---------------------------------------------------------------------------
|
req: CapabilityRequest,
|
||||||
|
|
||||||
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
|
|
||||||
async def dispute_request(
|
|
||||||
request_id: uuid.UUID,
|
|
||||||
body: CapabilityRequestDispute,
|
body: CapabilityRequestDispute,
|
||||||
session: AsyncSession = Depends(get_session),
|
now: datetime,
|
||||||
) -> CapabilityRequest:
|
) -> None:
|
||||||
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
|
|
||||||
req = await _get_request_or_404(request_id, session)
|
|
||||||
_check_transition(req.status, "routing_disputed")
|
|
||||||
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
|
||||||
req.status = "routing_disputed"
|
|
||||||
req.dispute_reason = body.reason
|
|
||||||
req.disputed_by = body.disputed_by
|
|
||||||
req.dispute_suggested_domain = body.suggested_domain
|
|
||||||
req.disputed_at = now
|
|
||||||
|
|
||||||
dispute_entry = (
|
dispute_entry = (
|
||||||
f"disputed by {body.disputed_by}: {body.reason}"
|
f"disputed by {body.disputed_by}: {body.reason}"
|
||||||
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
|
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
|
||||||
)
|
)
|
||||||
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
|
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
|
||||||
|
|
||||||
# Notify custodian
|
|
||||||
_add_notification(
|
_add_notification(
|
||||||
session,
|
session,
|
||||||
from_agent=body.disputed_by,
|
from_agent=body.disputed_by,
|
||||||
@@ -297,7 +233,6 @@ async def dispute_request(
|
|||||||
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
|
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Notify current fulfilling domain
|
|
||||||
if req.fulfilling_domain_slug:
|
if req.fulfilling_domain_slug:
|
||||||
_add_notification(
|
_add_notification(
|
||||||
session,
|
session,
|
||||||
@@ -312,52 +247,13 @@ async def dispute_request(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(req)
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
async def _notify_on_reroute(
|
||||||
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
|
session: AsyncSession,
|
||||||
async def reroute_request(
|
req: CapabilityRequest,
|
||||||
request_id: uuid.UUID,
|
|
||||||
body: CapabilityRequestReroute,
|
body: CapabilityRequestReroute,
|
||||||
session: AsyncSession = Depends(get_session),
|
new_domain_slug: str,
|
||||||
) -> CapabilityRequest:
|
) -> None:
|
||||||
"""Re-route a disputed request to a new domain. Resets to requested."""
|
|
||||||
req = await _get_request_or_404(request_id, session)
|
|
||||||
if req.status != "routing_disputed":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
|
|
||||||
)
|
|
||||||
if body.catalog_entry_id is None and body.domain is None:
|
|
||||||
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
|
|
||||||
|
|
||||||
if body.catalog_entry_id is not None:
|
|
||||||
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
|
|
||||||
if entry is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
|
||||||
req.catalog_entry_id = entry.id
|
|
||||||
req.fulfilling_domain_id = entry.domain_id
|
|
||||||
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
|
|
||||||
else:
|
|
||||||
new_domain = await _resolve_domain(body.domain, session)
|
|
||||||
req.fulfilling_domain_id = new_domain.id
|
|
||||||
new_domain_slug = new_domain.slug
|
|
||||||
|
|
||||||
old_domain = req.dispute_suggested_domain or "unknown"
|
|
||||||
|
|
||||||
# Clear dispute fields
|
|
||||||
req.dispute_reason = None
|
|
||||||
req.disputed_by = None
|
|
||||||
req.dispute_suggested_domain = None
|
|
||||||
req.disputed_at = None
|
|
||||||
req.status = "requested"
|
|
||||||
|
|
||||||
reroute_entry = f"re-routed by {body.rerouted_by} → {new_domain_slug}: {body.note}"
|
|
||||||
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
|
|
||||||
|
|
||||||
# Notify requester
|
|
||||||
_add_notification(
|
_add_notification(
|
||||||
session,
|
session,
|
||||||
from_agent=body.rerouted_by,
|
from_agent=body.rerouted_by,
|
||||||
@@ -368,7 +264,6 @@ async def reroute_request(
|
|||||||
f"**Note:** {body.note}"
|
f"**Note:** {body.note}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Notify new fulfilling domain
|
|
||||||
_add_notification(
|
_add_notification(
|
||||||
session,
|
session,
|
||||||
from_agent=body.rerouted_by,
|
from_agent=body.rerouted_by,
|
||||||
@@ -383,24 +278,20 @@ async def reroute_request(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(req)
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routing algorithm
|
# Routing algorithm
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def _route_capability(
|
async def _route_capability_match(
|
||||||
session: AsyncSession, capability_type: str, title: str, description: str
|
session: AsyncSession,
|
||||||
|
capability_type: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
||||||
"""Find the best-matching catalog entry for a capability request.
|
"""Find the best-matching catalog entry for a capability request.
|
||||||
|
|
||||||
Returns (domain_id, catalog_entry_id, routing_note).
|
Returns (domain_id, catalog_entry_id, routing_note).
|
||||||
Uses word-boundary matching on (title + description) combined to avoid
|
|
||||||
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
|
|
||||||
'ha' inside 'has').
|
|
||||||
"""
|
"""
|
||||||
q = select(CapabilityCatalog).where(
|
q = select(CapabilityCatalog).where(
|
||||||
CapabilityCatalog.capability_type == capability_type,
|
CapabilityCatalog.capability_type == capability_type,
|
||||||
@@ -412,20 +303,19 @@ async def _route_capability(
|
|||||||
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
||||||
|
|
||||||
if len(entries) == 1:
|
if len(entries) == 1:
|
||||||
e = entries[0]
|
entry = entries[0]
|
||||||
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
|
return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})"
|
||||||
|
|
||||||
# Score by word-boundary keyword overlap against title + description combined
|
|
||||||
combined = f"{title} {description or ''}".lower()
|
combined = f"{title} {description or ''}".lower()
|
||||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
||||||
score = sum(
|
score = sum(
|
||||||
1 for kw in keywords
|
1 for kw in keywords
|
||||||
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
|
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined)
|
||||||
)
|
)
|
||||||
scored.append((score, entry))
|
scored.append((score, entry))
|
||||||
scored.sort(key=lambda x: -x[0])
|
scored.sort(key=lambda item: -item[0])
|
||||||
|
|
||||||
best_score, best = scored[0]
|
best_score, best = scored[0]
|
||||||
if best_score == 0:
|
if best_score == 0:
|
||||||
@@ -456,7 +346,6 @@ def _add_notification(
|
|||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create an AgentMessage notification in the current session (no commit)."""
|
|
||||||
msg = AgentMessage(
|
msg = AgentMessage(
|
||||||
from_agent=from_agent,
|
from_agent=from_agent,
|
||||||
to_agent=to_agent,
|
to_agent=to_agent,
|
||||||
@@ -466,21 +355,6 @@ def _add_notification(
|
|||||||
session.add(msg)
|
session.add(msg)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
|
|
||||||
result = await session.execute(select(Domain).where(Domain.slug == slug))
|
|
||||||
domain = result.scalar_one_or_none()
|
|
||||||
if domain is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
|
||||||
return domain
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
|
|
||||||
req = await session.get(CapabilityRequest, request_id)
|
|
||||||
if req is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
|
||||||
return req
|
|
||||||
|
|
||||||
|
|
||||||
def _check_transition(current: str, target: str) -> None:
|
def _check_transition(current: str, target: str) -> None:
|
||||||
can_reach, failures, flow_result = evaluate_transition(
|
can_reach, failures, flow_result = evaluate_transition(
|
||||||
"capability_request",
|
"capability_request",
|
||||||
@@ -500,3 +374,44 @@ def _check_transition(current: str, target: str) -> None:
|
|||||||
"flow_result": flow_result_to_dict(flow_result),
|
"flow_result": flow_result_to_dict(flow_result),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = create_capability_catalog_router(
|
||||||
|
get_session,
|
||||||
|
domain_model=Domain,
|
||||||
|
repo_model=ManagedRepo,
|
||||||
|
catalog_model=CapabilityCatalog,
|
||||||
|
)
|
||||||
|
router.include_router(
|
||||||
|
create_capability_request_read_router(
|
||||||
|
get_session,
|
||||||
|
domain_model=Domain,
|
||||||
|
request_model=CapabilityRequest,
|
||||||
|
request_read_schema=CapabilityRequestRead,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
router.include_router(
|
||||||
|
create_capability_request_write_router(
|
||||||
|
get_session,
|
||||||
|
domain_model=Domain,
|
||||||
|
catalog_model=CapabilityCatalog,
|
||||||
|
request_model=CapabilityRequest,
|
||||||
|
request_create_schema=CapabilityRequestCreate,
|
||||||
|
request_accept_schema=CapabilityRequestAccept,
|
||||||
|
request_patch_schema=CapabilityRequestPatch,
|
||||||
|
request_status_patch_schema=CapabilityRequestStatusPatch,
|
||||||
|
request_dispute_schema=CapabilityRequestDispute,
|
||||||
|
request_reroute_schema=CapabilityRequestReroute,
|
||||||
|
request_read_schema=CapabilityRequestRead,
|
||||||
|
route_request=_route_capability,
|
||||||
|
build_request=_build_capability_request,
|
||||||
|
on_request_persisted=_notify_on_create,
|
||||||
|
check_transition=_check_transition,
|
||||||
|
apply_accept_fields=_apply_accept_fields,
|
||||||
|
after_accept=_notify_on_accept,
|
||||||
|
after_status_change=_on_status_change,
|
||||||
|
apply_patch=_apply_capability_patch,
|
||||||
|
after_dispute=_notify_on_dispute,
|
||||||
|
after_reroute=_notify_on_reroute,
|
||||||
|
)
|
||||||
|
)
|
||||||
37
api/routers/consistency_sweep.py
Normal file
37
api/routers/consistency_sweep.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.database import get_session
|
||||||
|
from api.schemas.consistency_sweep import (
|
||||||
|
ConsistencySweepRemoteAllGenerate,
|
||||||
|
ConsistencySweepRemoteAllRun,
|
||||||
|
)
|
||||||
|
from api.services.consistency_sweep import run_remote_all_sweep
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/consistency/sweep", tags=["consistency"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/remote-all",
|
||||||
|
response_model=ConsistencySweepRemoteAllRun,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def sweep_remote_all(
|
||||||
|
body: ConsistencySweepRemoteAllGenerate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ConsistencySweepRemoteAllRun:
|
||||||
|
try:
|
||||||
|
return await run_remote_all_sweep(
|
||||||
|
session,
|
||||||
|
max_seconds=body.max_seconds,
|
||||||
|
source=body.source,
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Consistency sweep returned invalid JSON: {exc}",
|
||||||
|
) from exc
|
||||||
@@ -43,7 +43,7 @@ async def create_contribution(
|
|||||||
title=body.title,
|
title=body.title,
|
||||||
body_path=body.body_path,
|
body_path=body.body_path,
|
||||||
related_topic_id=body.related_topic_id,
|
related_topic_id=body.related_topic_id,
|
||||||
related_workstream_id=body.related_workstream_id,
|
related_workplan_id=body.related_workplan_id,
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
status=ContributionStatus.draft,
|
status=ContributionStatus.draft,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
|
|||||||
@router.get("/", response_model=list[DecisionRead])
|
@router.get("/", response_model=list[DecisionRead])
|
||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
topic_id: uuid.UUID | None = None,
|
topic_id: uuid.UUID | None = None,
|
||||||
|
workplan_id: uuid.UUID | None = None,
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: DecisionStatus | None = None,
|
status: DecisionStatus | None = None,
|
||||||
decision_type: DecisionType | None = None,
|
decision_type: DecisionType | None = None,
|
||||||
@@ -48,8 +49,9 @@ async def list_decisions(
|
|||||||
q = select(Decision)
|
q = select(Decision)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
q = q.where(Decision.topic_id == topic_id)
|
q = q.where(Decision.topic_id == topic_id)
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
q = q.where(Decision.workstream_id == workstream_id)
|
if scope_id:
|
||||||
|
q = q.where(Decision.workplan_id == scope_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Decision.status == status)
|
q = q.where(Decision.status == status)
|
||||||
if decision_type:
|
if decision_type:
|
||||||
@@ -139,7 +141,7 @@ async def resolve_decision_action(
|
|||||||
|
|
||||||
event = ProgressEvent(
|
event = ProgressEvent(
|
||||||
topic_id=decision.topic_id,
|
topic_id=decision.topic_id,
|
||||||
workstream_id=decision.workstream_id,
|
workplan_id=decision.workplan_id,
|
||||||
decision_id=decision.id,
|
decision_id=decision.id,
|
||||||
event_type="decision_resolved",
|
event_type="decision_resolved",
|
||||||
summary=f"Decision resolved: {decision.title}",
|
summary=f"Decision resolved: {decision.title}",
|
||||||
@@ -159,7 +161,7 @@ async def resolve_decision_action(
|
|||||||
"decision_id": str(decision.id),
|
"decision_id": str(decision.id),
|
||||||
"title": decision.title,
|
"title": decision.title,
|
||||||
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
||||||
"workstream_id": str(decision.workstream_id) if decision.workstream_id else None,
|
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
|
||||||
"decided_by": body.decided_by,
|
"decided_by": body.decided_by,
|
||||||
"rationale_snippet": (body.rationale or "")[:240],
|
"rationale_snippet": (body.rationale or "")[:240],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from api.models.extension_point import ExtensionPoint
|
|||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.technical_debt import TechnicalDebt
|
from api.models.technical_debt import TechnicalDebt
|
||||||
from api.models.topic import Topic
|
from api.models.topic import Topic
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.domain import (
|
from api.schemas.domain import (
|
||||||
DomainCreate,
|
DomainCreate,
|
||||||
DomainDetail,
|
DomainDetail,
|
||||||
@@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD
|
|||||||
workstream_count = 0
|
workstream_count = 0
|
||||||
if topic_ids:
|
if topic_ids:
|
||||||
workstream_count_row = await session.execute(
|
workstream_count_row = await session.execute(
|
||||||
select(func.count()).select_from(Workstream)
|
select(func.count()).select_from(Workplan)
|
||||||
.where(Workstream.topic_id.in_(topic_ids))
|
.where(Workplan.topic_id.in_(topic_ids))
|
||||||
.where(Workstream.status == "active")
|
.where(Workplan.status == "active")
|
||||||
)
|
)
|
||||||
workstream_count = workstream_count_row.scalar_one()
|
workstream_count = workstream_count_row.scalar_one()
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.schemas.execution import (
|
from api.schemas.execution import (
|
||||||
ExecutionIntentRead,
|
ExecutionIntentRead,
|
||||||
ExecutionIntentUpdate,
|
ExecutionIntentUpdate,
|
||||||
@@ -25,10 +25,10 @@ from api.services.execution_queue import (
|
|||||||
STATE_HUB_RESPONSIBILITIES,
|
STATE_HUB_RESPONSIBILITIES,
|
||||||
execution_state_for_launch,
|
execution_state_for_launch,
|
||||||
queue_sort_key,
|
queue_sort_key,
|
||||||
workstream_blockers,
|
workplan_blockers,
|
||||||
)
|
)
|
||||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/execution", tags=["execution"])
|
router = APIRouter(prefix="/execution", tags=["execution"])
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ async def _update_execution_intent(
|
|||||||
body: ExecutionIntentUpdate,
|
body: ExecutionIntentUpdate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> ExecutionIntentRead:
|
) -> ExecutionIntentRead:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
ws = await session.get(Workplan, workstream_id)
|
||||||
if ws is None:
|
if ws is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
|
|
||||||
@@ -94,22 +94,22 @@ async def workplan_stack(
|
|||||||
include_blocked: bool = Query(True),
|
include_blocked: bool = Query(True),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[WorkplanQueueItem]:
|
) -> list[WorkplanQueueItem]:
|
||||||
result = await session.execute(select(Workstream))
|
result = await session.execute(select(Workplan))
|
||||||
workstreams = [
|
workstreams = [
|
||||||
ws for ws in result.scalars().all()
|
ws for ws in result.scalars().all()
|
||||||
if normalize_workstream_status(ws.status) not in CLOSED_WORKSTREAM_STATUSES
|
if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES
|
||||||
]
|
]
|
||||||
ws_by_id = {ws.id: ws for ws in workstreams}
|
ws_by_id = {ws.id: ws for ws in workstreams}
|
||||||
ws_status = {ws.id: normalize_workstream_status(ws.status) for ws in workstreams}
|
ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams}
|
||||||
|
|
||||||
dep_result = await session.execute(select(WorkstreamDependency))
|
dep_result = await session.execute(select(WorkplanDependency))
|
||||||
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||||
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||||
for dep in dep_result.scalars().all():
|
for dep in dep_result.scalars().all():
|
||||||
if dep.to_workstream_id is not None:
|
if dep.to_workplan_id is not None:
|
||||||
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id)
|
ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
|
||||||
if dep.to_task_id is not None:
|
if dep.to_task_id is not None:
|
||||||
task_deps.setdefault(dep.from_workstream_id, []).append(dep.to_task_id)
|
task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id)
|
||||||
|
|
||||||
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
|
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
|
||||||
task_status: dict[uuid.UUID, str] = {}
|
task_status: dict[uuid.UUID, str] = {}
|
||||||
@@ -121,9 +121,9 @@ async def workplan_stack(
|
|||||||
for ws in workstreams:
|
for ws in workstreams:
|
||||||
if not include_manual and ws.execution_state == "manual":
|
if not include_manual and ws.execution_state == "manual":
|
||||||
continue
|
continue
|
||||||
lifecycle_status = normalize_workstream_status(ws.status)
|
lifecycle_status = normalize_workplan_status(ws.status)
|
||||||
blocked_ws = [
|
blocked_ws = [
|
||||||
blocker for blocker in workstream_blockers(ws.id, ws_deps, ws_status)
|
blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status)
|
||||||
if blocker in ws_by_id or blocker in ws_status
|
if blocker in ws_by_id or blocker in ws_status
|
||||||
]
|
]
|
||||||
blocked_tasks = [
|
blocked_tasks = [
|
||||||
@@ -135,7 +135,7 @@ async def workplan_stack(
|
|||||||
continue
|
continue
|
||||||
sort_key = queue_sort_key(ws, eligible=eligible)
|
sort_key = queue_sort_key(ws, eligible=eligible)
|
||||||
items.append(WorkplanQueueItem(
|
items.append(WorkplanQueueItem(
|
||||||
workstream_id=ws.id,
|
workplan_id=ws.id,
|
||||||
slug=ws.slug,
|
slug=ws.slug,
|
||||||
title=ws.title,
|
title=ws.title,
|
||||||
status=lifecycle_status,
|
status=lifecycle_status,
|
||||||
@@ -149,7 +149,7 @@ async def workplan_stack(
|
|||||||
execution_group=ws.execution_group,
|
execution_group=ws.execution_group,
|
||||||
scheduled_for=ws.scheduled_for,
|
scheduled_for=ws.scheduled_for,
|
||||||
eligible=eligible,
|
eligible=eligible,
|
||||||
blocked_by_workstream_ids=blocked_ws,
|
blocked_by_workplan_ids=blocked_ws,
|
||||||
blocked_by_task_ids=blocked_tasks,
|
blocked_by_task_ids=blocked_tasks,
|
||||||
sort_key=sort_key,
|
sort_key=sort_key,
|
||||||
))
|
))
|
||||||
@@ -165,12 +165,12 @@ async def create_launch_request(
|
|||||||
body: LaunchRequestCreate,
|
body: LaunchRequestCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkplanLaunchRequest:
|
) -> WorkplanLaunchRequest:
|
||||||
ws = await session.get(Workstream, body.workstream_id)
|
ws = await session.get(Workplan, body.workplan_id)
|
||||||
if ws is None:
|
if ws is None:
|
||||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
|
|
||||||
launch_request = WorkplanLaunchRequest(
|
launch_request = WorkplanLaunchRequest(
|
||||||
workstream_id=ws.id,
|
workplan_id=ws.id,
|
||||||
requested_by=body.requested_by,
|
requested_by=body.requested_by,
|
||||||
requested_actor=body.requested_actor,
|
requested_actor=body.requested_actor,
|
||||||
launch_mode=body.launch_mode,
|
launch_mode=body.launch_mode,
|
||||||
@@ -199,16 +199,16 @@ async def list_launch_requests(
|
|||||||
) -> list[WorkplanLaunchRequest]:
|
) -> list[WorkplanLaunchRequest]:
|
||||||
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
||||||
if workstream_id:
|
if workstream_id:
|
||||||
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id)
|
q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
|
||||||
if request_status:
|
if request_status:
|
||||||
q = q.where(WorkplanLaunchRequest.status == request_status)
|
q = q.where(WorkplanLaunchRequest.status == request_status)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
def _intent_read(ws: Workstream) -> ExecutionIntentRead:
|
def _intent_read(ws: Workplan) -> ExecutionIntentRead:
|
||||||
return ExecutionIntentRead(
|
return ExecutionIntentRead(
|
||||||
workstream_id=ws.id,
|
workplan_id=ws.id,
|
||||||
execution_state=ws.execution_state,
|
execution_state=ws.execution_state,
|
||||||
launch_mode=ws.launch_mode,
|
launch_mode=ws.launch_mode,
|
||||||
concurrency_mode=ws.concurrency_mode,
|
concurrency_mode=ws.concurrency_mode,
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ from api.flow_defs import (
|
|||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.contribution import Contribution
|
from api.models.contribution import Contribution
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.services.lifecycle import transition_task_status, transition_workstream_status
|
from api.services.lifecycle import transition_task_status, transition_workplan_status
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ async def advance_workstation(
|
|||||||
|
|
||||||
entity = await _entity(entity_type, entity_id, session)
|
entity = await _entity(entity_type, entity_id, session)
|
||||||
if entity_type == "workstream":
|
if entity_type == "workstream":
|
||||||
transition_workstream_status(entity, target_workstation)
|
transition_workplan_status(entity, target_workstation)
|
||||||
elif entity_type == "task":
|
elif entity_type == "task":
|
||||||
parent = await session.get(Workstream, entity.workstream_id)
|
parent = await session.get(Workplan, entity.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
entity,
|
entity,
|
||||||
target_workstation,
|
target_workstation,
|
||||||
@@ -117,7 +117,7 @@ async def _flow_object(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
entity = await _entity(entity_type, entity_id, session)
|
entity = await _entity(entity_type, entity_id, session)
|
||||||
status = _value(entity.status)
|
status = _value(entity.status)
|
||||||
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
|
current_status = normalize_workplan_status(status) if entity_type == "workstream" else status
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
"id": str(entity.id),
|
"id": str(entity.id),
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
@@ -127,21 +127,21 @@ async def _flow_object(
|
|||||||
|
|
||||||
if entity_type == "workstream":
|
if entity_type == "workstream":
|
||||||
tasks = list((await session.execute(
|
tasks = list((await session.execute(
|
||||||
select(Task).where(Task.workstream_id == entity_id)
|
select(Task).where(Task.workplan_id == entity_id)
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
deps = list((await session.execute(
|
deps = list((await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
WorkstreamDependency.from_workstream_id == entity_id
|
WorkplanDependency.from_workplan_id == entity_id
|
||||||
)
|
)
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
dependency_ids = [dep.to_workstream_id for dep in deps]
|
dependency_ids = [dep.to_workplan_id for dep in deps]
|
||||||
dependency_workstations: list[dict[str, Any]] = []
|
dependency_workstations: list[dict[str, Any]] = []
|
||||||
if dependency_ids:
|
if dependency_ids:
|
||||||
dep_ws = list((await session.execute(
|
dep_ws = list((await session.execute(
|
||||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
select(Workplan).where(Workplan.id.in_(dependency_ids))
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
dependency_workstations = [
|
dependency_workstations = [
|
||||||
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
|
{"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)}
|
||||||
for ws in dep_ws
|
for ws in dep_ws
|
||||||
]
|
]
|
||||||
obj.update({
|
obj.update({
|
||||||
@@ -163,7 +163,7 @@ async def _entity(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
):
|
):
|
||||||
model_by_type = {
|
model_by_type = {
|
||||||
"workstream": Workstream,
|
"workstream": Workplan,
|
||||||
"task": Task,
|
"task": Task,
|
||||||
"contribution": Contribution,
|
"contribution": Contribution,
|
||||||
"capability_request": CapabilityRequest,
|
"capability_request": CapabilityRequest,
|
||||||
|
|||||||
@@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage
|
|||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.task import TaskStatus
|
from api.models.task import TaskStatus
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
||||||
from api.services.lifecycle import (
|
from api.services.lifecycle import (
|
||||||
should_activate_parent_for_task_start,
|
should_activate_parent_for_task_start,
|
||||||
status_value,
|
status_value,
|
||||||
transition_task_status,
|
transition_task_status,
|
||||||
transition_workstream_status,
|
transition_workplan_status,
|
||||||
)
|
)
|
||||||
from api.task_status import TERMINAL_TASK_STATUSES
|
from api.task_status import TERMINAL_TASK_STATUSES
|
||||||
from api.services.reconciliation import (
|
from api.services.reconciliation import (
|
||||||
ReconciliationClass,
|
ReconciliationClass,
|
||||||
StateChangeClassification,
|
StateChangeClassification,
|
||||||
classify_task_status_change,
|
classify_task_status_change,
|
||||||
classify_workstream_status_change,
|
classify_workplan_status_change,
|
||||||
)
|
)
|
||||||
from api.services.workplan_files import (
|
from api.services.workplan_files import (
|
||||||
find_workplan_for_workstream,
|
find_workplan_for_workplan,
|
||||||
patch_task_status,
|
patch_task_status,
|
||||||
patch_workplan_status,
|
patch_workplan_status,
|
||||||
resolve_repo_path,
|
resolve_repo_path,
|
||||||
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
|
|||||||
task_block_linked,
|
task_block_linked,
|
||||||
workplan_status,
|
workplan_status,
|
||||||
)
|
)
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
|||||||
|
|
||||||
|
|
||||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||||
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
|
result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id))
|
||||||
statuses = [status_value(row[0]) for row in result.all()]
|
statuses = [status_value(row[0]) for row in result.all()]
|
||||||
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||||
|
|
||||||
@@ -98,13 +98,13 @@ async def classify_state_change(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> StateChangeResponse:
|
) -> StateChangeResponse:
|
||||||
if body.target_type == "workstream":
|
if body.target_type == "workstream":
|
||||||
ws = await session.get(Workstream, body.target_id)
|
ws = await session.get(Workplan, body.target_id)
|
||||||
if ws is None:
|
if ws is None:
|
||||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
|
|
||||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
||||||
repo_path = resolve_repo_path(repo)
|
repo_path = resolve_repo_path(repo)
|
||||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if repo_path else None
|
workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None
|
||||||
actual_file_backed = workplan_ref is not None
|
actual_file_backed = workplan_ref is not None
|
||||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||||
file_backed = (
|
file_backed = (
|
||||||
@@ -122,9 +122,9 @@ async def classify_state_change(
|
|||||||
if body.tasks_terminal is not None
|
if body.tasks_terminal is not None
|
||||||
else await _workstream_tasks_terminal(session, ws.id)
|
else await _workstream_tasks_terminal(session, ws.id)
|
||||||
)
|
)
|
||||||
current_status = normalize_workstream_status(ws.status)
|
current_status = normalize_workplan_status(ws.status)
|
||||||
target_status = normalize_workstream_status(body.target_status)
|
target_status = normalize_workplan_status(body.target_status)
|
||||||
classification = classify_workstream_status_change(
|
classification = classify_workplan_status_change(
|
||||||
current_status=current_status,
|
current_status=current_status,
|
||||||
target_status=target_status,
|
target_status=target_status,
|
||||||
file_backed=file_backed,
|
file_backed=file_backed,
|
||||||
@@ -136,7 +136,7 @@ async def classify_state_change(
|
|||||||
conflict = False
|
conflict = False
|
||||||
if body.apply:
|
if body.apply:
|
||||||
expected_status = (
|
expected_status = (
|
||||||
normalize_workstream_status(body.expected_current_status)
|
normalize_workplan_status(body.expected_current_status)
|
||||||
if body.expected_current_status is not None
|
if body.expected_current_status is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -153,7 +153,7 @@ async def classify_state_change(
|
|||||||
)
|
)
|
||||||
conflict = True
|
conflict = True
|
||||||
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
||||||
file_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
file_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||||
if file_status and file_status != current_status:
|
if file_status and file_status != current_status:
|
||||||
classification = _conflict(
|
classification = _conflict(
|
||||||
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
||||||
@@ -163,7 +163,7 @@ async def classify_state_change(
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
patch_workplan_status(workplan_ref.path, target_status)
|
patch_workplan_status(workplan_ref.path, target_status)
|
||||||
patched_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
patched_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
classification = _conflict(
|
classification = _conflict(
|
||||||
f"workplan file write failed: {exc}",
|
f"workplan file write failed: {exc}",
|
||||||
@@ -178,7 +178,7 @@ async def classify_state_change(
|
|||||||
)
|
)
|
||||||
conflict = True
|
conflict = True
|
||||||
else:
|
else:
|
||||||
transition_workstream_status(ws, target_status)
|
transition_workplan_status(ws, target_status)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
write_result = "applied"
|
write_result = "applied"
|
||||||
|
|
||||||
@@ -221,10 +221,10 @@ async def classify_state_change(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
||||||
repo_path = resolve_repo_path(repo)
|
repo_path = resolve_repo_path(repo)
|
||||||
workplan_ref = find_workplan_for_workstream(repo, ws.id) if ws and repo_path else None
|
workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None
|
||||||
actual_file_backed = workplan_ref is not None
|
actual_file_backed = workplan_ref is not None
|
||||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||||
file_backed = (
|
file_backed = (
|
||||||
@@ -291,7 +291,7 @@ async def classify_state_change(
|
|||||||
parent_will_activate = should_activate_parent_for_task_start(
|
parent_will_activate = should_activate_parent_for_task_start(
|
||||||
previous_task_status=current_status,
|
previous_task_status=current_status,
|
||||||
new_task_status=target_status,
|
new_task_status=target_status,
|
||||||
parent_workstream_status=ws.status if ws else None,
|
parent_workplan_status=ws.status if ws else None,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
||||||
@@ -299,7 +299,7 @@ async def classify_state_change(
|
|||||||
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
||||||
if parent_will_activate:
|
if parent_will_activate:
|
||||||
patch_workplan_status(workplan_ref.path, "active")
|
patch_workplan_status(workplan_ref.path, "active")
|
||||||
parent_status = normalize_workstream_status(workplan_status(workplan_ref.path))
|
parent_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||||
if parent_status != "active":
|
if parent_status != "active":
|
||||||
if original_text is not None:
|
if original_text is not None:
|
||||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import noload
|
||||||
|
|
||||||
from api.config import settings
|
from api.config import settings
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
@@ -29,11 +30,11 @@ from api.models.managed_repo import ManagedRepo
|
|||||||
from api.models.repo_goal import RepoGoal
|
from api.models.repo_goal import RepoGoal
|
||||||
from api.models.tpsc import TPSCSnapshot
|
from api.models.tpsc import TPSCSnapshot
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||||
from api.schemas.managed_repo import (
|
from api.schemas.managed_repo import (
|
||||||
DispatchTask,
|
DispatchTask,
|
||||||
DispatchWorkstream,
|
DispatchWorkplan,
|
||||||
PendingInterfaceChange,
|
PendingInterfaceChange,
|
||||||
RepoCreate,
|
RepoCreate,
|
||||||
RepoDispatch,
|
RepoDispatch,
|
||||||
@@ -44,6 +45,8 @@ from api.schemas.managed_repo import (
|
|||||||
RepoScopeHealth,
|
RepoScopeHealth,
|
||||||
RepoUpdate,
|
RepoUpdate,
|
||||||
ScopeIssueDetail,
|
ScopeIssueDetail,
|
||||||
|
classification_fields_set,
|
||||||
|
validate_repo_classification_fields,
|
||||||
)
|
)
|
||||||
from hub_core.routers.repos import create_repos_router
|
from hub_core.routers.repos import create_repos_router
|
||||||
|
|
||||||
@@ -76,13 +79,107 @@ def _core_repo_router(**route_flags) -> APIRouter:
|
|||||||
repo_read_schema=RepoRead,
|
repo_read_schema=RepoRead,
|
||||||
repo_path_register_schema=RepoPathRegister,
|
repo_path_register_schema=RepoPathRegister,
|
||||||
list_noload_fields=("goals",),
|
list_noload_fields=("goals",),
|
||||||
create_extension_fields=("topic_id",),
|
create_extension_fields=(
|
||||||
|
"topic_id",
|
||||||
|
"category",
|
||||||
|
"secondary_domains",
|
||||||
|
"capability_tags",
|
||||||
|
"business_stake",
|
||||||
|
"business_mechanics",
|
||||||
|
"classified_at",
|
||||||
|
"classified_by",
|
||||||
|
"standard_version",
|
||||||
|
),
|
||||||
after_register=_publish_repo_registered,
|
after_register=_publish_repo_registered,
|
||||||
**route_flags,
|
**route_flags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
router.include_router(_core_repo_router(include_slug_routes=False))
|
router.include_router(
|
||||||
|
_core_repo_router(include_collection_routes=False, include_slug_routes=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[RepoRead])
|
||||||
|
async def list_repos(
|
||||||
|
response: Response,
|
||||||
|
domain: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
capability_tag: str | None = None,
|
||||||
|
business_stake: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[ManagedRepo]:
|
||||||
|
"""List repos with optional domain and classification filters."""
|
||||||
|
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||||
|
q = (
|
||||||
|
select(ManagedRepo)
|
||||||
|
.options(noload(ManagedRepo.goals))
|
||||||
|
.order_by(ManagedRepo.name)
|
||||||
|
)
|
||||||
|
if domain:
|
||||||
|
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
|
||||||
|
domain_obj = domain_result.scalar_one_or_none()
|
||||||
|
if domain_obj is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
|
||||||
|
q = q.where(
|
||||||
|
or_(
|
||||||
|
ManagedRepo.domain_id == domain_obj.id,
|
||||||
|
ManagedRepo.secondary_domains.contains([domain]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if category:
|
||||||
|
q = q.where(ManagedRepo.category == category)
|
||||||
|
if capability_tag:
|
||||||
|
q = q.where(ManagedRepo.capability_tags.contains([capability_tag]))
|
||||||
|
if business_stake:
|
||||||
|
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register_repo(
|
||||||
|
body: RepoCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ManagedRepo:
|
||||||
|
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
|
||||||
|
domain_obj = domain_result.scalar_one_or_none()
|
||||||
|
if domain_obj is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
||||||
|
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||||
|
|
||||||
|
payload = body.model_dump()
|
||||||
|
validate_repo_classification_fields(
|
||||||
|
domain_slug=body.domain_slug,
|
||||||
|
fields=payload,
|
||||||
|
require_complete=classification_fields_set(payload),
|
||||||
|
)
|
||||||
|
repo = ManagedRepo(
|
||||||
|
domain_id=domain_obj.id,
|
||||||
|
slug=body.slug,
|
||||||
|
name=body.name,
|
||||||
|
local_path=body.local_path,
|
||||||
|
host_paths=body.host_paths,
|
||||||
|
remote_url=body.remote_url,
|
||||||
|
git_fingerprint=body.git_fingerprint,
|
||||||
|
description=body.description,
|
||||||
|
topic_id=body.topic_id,
|
||||||
|
category=body.category,
|
||||||
|
secondary_domains=body.secondary_domains,
|
||||||
|
capability_tags=body.capability_tags,
|
||||||
|
business_stake=body.business_stake,
|
||||||
|
business_mechanics=body.business_mechanics,
|
||||||
|
classified_at=body.classified_at,
|
||||||
|
classified_by=body.classified_by,
|
||||||
|
standard_version=body.standard_version,
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
await _publish_repo_registered(repo, body, domain_obj)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
@router.post("/onboard", response_model=RepoOnboardResult)
|
@router.post("/onboard", response_model=RepoOnboardResult)
|
||||||
@@ -428,6 +525,38 @@ async def list_repo_scope_health(
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{slug}", response_model=RepoRead)
|
||||||
|
async def update_repo_with_classification(
|
||||||
|
slug: str,
|
||||||
|
body: RepoUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ManagedRepo:
|
||||||
|
"""Patch repo metadata including classification spine fields."""
|
||||||
|
repo = await _get_repo_by_slug(slug, session)
|
||||||
|
payload = body.model_dump(exclude_unset=True)
|
||||||
|
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||||
|
domain_obj = domain_result.scalar_one_or_none()
|
||||||
|
domain_slug = domain_obj.slug if domain_obj else ""
|
||||||
|
if classification_fields_set(payload):
|
||||||
|
merged = {
|
||||||
|
"category": payload.get("category", repo.category),
|
||||||
|
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
|
||||||
|
"capability_tags": payload.get("capability_tags", repo.capability_tags),
|
||||||
|
"business_stake": payload.get("business_stake", repo.business_stake),
|
||||||
|
"business_mechanics": payload.get("business_mechanics", repo.business_mechanics),
|
||||||
|
}
|
||||||
|
validate_repo_classification_fields(
|
||||||
|
domain_slug=domain_slug,
|
||||||
|
fields=merged,
|
||||||
|
require_complete=True,
|
||||||
|
)
|
||||||
|
for field, value in payload.items():
|
||||||
|
setattr(repo, field, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
_core_repo_router(
|
_core_repo_router(
|
||||||
include_collection_routes=False,
|
include_collection_routes=False,
|
||||||
@@ -480,19 +609,19 @@ async def get_repo_dispatch(
|
|||||||
|
|
||||||
# Active workstreams
|
# Active workstreams
|
||||||
ws_result = await session.execute(
|
ws_result = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
|
.where(Workplan.repo_id == repo.id, Workplan.status == "active")
|
||||||
.order_by(Workstream.created_at)
|
.order_by(Workplan.created_at)
|
||||||
)
|
)
|
||||||
workstreams = list(ws_result.scalars().all())
|
workstreams = list(ws_result.scalars().all())
|
||||||
|
|
||||||
dispatch_workstreams: list[DispatchWorkstream] = []
|
dispatch_workstreams: list[DispatchWorkplan] = []
|
||||||
all_interventions: list[DispatchTask] = []
|
all_interventions: list[DispatchTask] = []
|
||||||
|
|
||||||
for ws in workstreams:
|
for ws in workstreams:
|
||||||
task_result = await session.execute(
|
task_result = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
|
.where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"]))
|
||||||
.order_by(Task.created_at)
|
.order_by(Task.created_at)
|
||||||
)
|
)
|
||||||
tasks = list(task_result.scalars().all())
|
tasks = list(task_result.scalars().all())
|
||||||
@@ -511,7 +640,7 @@ async def get_repo_dispatch(
|
|||||||
all_interventions.extend(interventions)
|
all_interventions.extend(interventions)
|
||||||
|
|
||||||
dispatch_workstreams.append(
|
dispatch_workstreams.append(
|
||||||
DispatchWorkstream(
|
DispatchWorkplan(
|
||||||
id=ws.id,
|
id=ws.id,
|
||||||
title=ws.title,
|
title=ws.title,
|
||||||
status=ws.status,
|
status=ws.status,
|
||||||
@@ -554,7 +683,7 @@ async def get_repo_dispatch(
|
|||||||
return RepoDispatch(
|
return RepoDispatch(
|
||||||
repo_slug=slug,
|
repo_slug=slug,
|
||||||
active_goal=active_goal,
|
active_goal=active_goal,
|
||||||
active_workstreams=dispatch_workstreams,
|
active_workplans=dispatch_workstreams,
|
||||||
human_interventions=all_interventions,
|
human_interventions=all_interventions,
|
||||||
pending_interface_changes=pending_changes,
|
pending_interface_changes=pending_changes,
|
||||||
scope_needs_review=scope_needs_review,
|
scope_needs_review=scope_needs_review,
|
||||||
|
|||||||
143
api/routers/services.py
Normal file
143
api/routers/services.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Two-dimension service catalog API (STATE-WP-0062).
|
||||||
|
|
||||||
|
Read/write surface over service_catalog and its per-dimension extension tables.
|
||||||
|
The four service classes are queried by combining the hosting_type and
|
||||||
|
development_type filters. The legacy /tpsc routes remain for third-party
|
||||||
|
dependency snapshots; this router is the source of truth for the catalog itself.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from api.database import get_session
|
||||||
|
from api.models.managed_repo import ManagedRepo
|
||||||
|
from api.models.service_catalog import (
|
||||||
|
ServiceCatalog,
|
||||||
|
ServiceCloud,
|
||||||
|
ServiceFirstParty,
|
||||||
|
ServiceSelfHosted,
|
||||||
|
ServiceThirdParty,
|
||||||
|
)
|
||||||
|
from api.schemas.service import ServiceCatalogRead, ServiceUpsert
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/services", tags=["services"])
|
||||||
|
|
||||||
|
_HOSTING = {"self_hosted", "cloud_hosted"}
|
||||||
|
_DEVELOPMENT = {"first_party", "third_party"}
|
||||||
|
|
||||||
|
_WITH_EXTENSIONS = (
|
||||||
|
selectinload(ServiceCatalog.third_party),
|
||||||
|
selectinload(ServiceCatalog.first_party),
|
||||||
|
selectinload(ServiceCatalog.cloud),
|
||||||
|
selectinload(ServiceCatalog.self_hosted),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog", response_model=list[ServiceCatalogRead])
|
||||||
|
async def list_services(
|
||||||
|
hosting_type: str | None = None,
|
||||||
|
development_type: str | None = None,
|
||||||
|
maturity_level: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[ServiceCatalog]:
|
||||||
|
q = select(ServiceCatalog).options(*_WITH_EXTENSIONS)
|
||||||
|
if hosting_type:
|
||||||
|
q = q.where(ServiceCatalog.hosting_type == hosting_type)
|
||||||
|
if development_type:
|
||||||
|
q = q.where(ServiceCatalog.development_type == development_type)
|
||||||
|
if maturity_level is not None:
|
||||||
|
q = q.where(ServiceCatalog.maturity_level == maturity_level)
|
||||||
|
if status:
|
||||||
|
q = q.where(ServiceCatalog.status == status)
|
||||||
|
q = q.order_by(ServiceCatalog.name.asc())
|
||||||
|
result = await session.execute(q)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=ServiceCatalogRead)
|
||||||
|
async def get_service(
|
||||||
|
slug: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ServiceCatalog:
|
||||||
|
svc = await _resolve(slug, session)
|
||||||
|
if svc is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Service '{slug}' not found")
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/catalog", response_model=ServiceCatalogRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upsert_service(
|
||||||
|
body: ServiceUpsert,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ServiceCatalog:
|
||||||
|
if body.hosting_type not in _HOSTING:
|
||||||
|
raise HTTPException(status_code=422, detail=f"hosting_type must be one of {sorted(_HOSTING)}")
|
||||||
|
if body.development_type not in _DEVELOPMENT:
|
||||||
|
raise HTTPException(status_code=422, detail=f"development_type must be one of {sorted(_DEVELOPMENT)}")
|
||||||
|
|
||||||
|
svc = await _resolve(body.slug, session)
|
||||||
|
if svc is None:
|
||||||
|
svc = ServiceCatalog(slug=body.slug)
|
||||||
|
session.add(svc)
|
||||||
|
|
||||||
|
for field in ("name", "owner_or_provider", "category", "description",
|
||||||
|
"website_url", "status", "hosting_type", "development_type",
|
||||||
|
"maturity_level"):
|
||||||
|
setattr(svc, field, getattr(body, field))
|
||||||
|
|
||||||
|
await _apply_extensions(svc, body, session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return await _resolve(body.slug, session)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _resolve(slug: str, session: AsyncSession) -> ServiceCatalog | None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ServiceCatalog).where(ServiceCatalog.slug == slug).options(*_WITH_EXTENSIONS)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_ext(model, service_id: uuid.UUID, data: dict, session: AsyncSession) -> None:
|
||||||
|
"""Create or update a 1:1 extension row keyed by service_id.
|
||||||
|
|
||||||
|
Fetched via session.get (not the relationship attribute) so we never trigger
|
||||||
|
a lazy relationship load on a freshly-created core row in async context.
|
||||||
|
"""
|
||||||
|
current = await session.get(model, service_id)
|
||||||
|
if current is None:
|
||||||
|
current = model(service_id=service_id)
|
||||||
|
session.add(current)
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(current, k, v)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: AsyncSession) -> None:
|
||||||
|
# Ensure svc.id is available for new rows.
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if body.third_party is not None:
|
||||||
|
await _upsert_ext(ServiceThirdParty, svc.id, body.third_party.model_dump(), session)
|
||||||
|
if body.cloud is not None:
|
||||||
|
await _upsert_ext(ServiceCloud, svc.id, body.cloud.model_dump(), session)
|
||||||
|
if body.self_hosted is not None:
|
||||||
|
await _upsert_ext(ServiceSelfHosted, svc.id, body.self_hosted.model_dump(), session)
|
||||||
|
if body.first_party is not None:
|
||||||
|
data = body.first_party.model_dump(exclude={"repo_slug"})
|
||||||
|
if body.first_party.repo_slug and not data.get("repo_id"):
|
||||||
|
repo = (await session.execute(
|
||||||
|
select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if repo is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found")
|
||||||
|
data["repo_id"] = repo.id
|
||||||
|
await _upsert_ext(ServiceFirstParty, svc.id, data, session)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy import func, select, text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import noload, selectinload
|
from sqlalchemy.orm import noload, selectinload
|
||||||
|
|
||||||
from api.database import get_session, engine
|
from api.database import get_session
|
||||||
from api.flow_defs import assertion_result_to_dict, load_flow
|
from api.flow_defs import assertion_result_to_dict, load_flow
|
||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.contribution import Contribution, ContributionStatus, ContributionType
|
from api.models.contribution import Contribution, ContributionStatus, ContributionType
|
||||||
@@ -21,8 +21,8 @@ from api.models.sbom_snapshot import SBOMSnapshot
|
|||||||
from api.models.task import Task, TaskPriority, TaskStatus
|
from api.models.task import Task, TaskPriority, TaskStatus
|
||||||
from api.models.technical_debt import TechnicalDebt
|
from api.models.technical_debt import TechnicalDebt
|
||||||
from api.models.topic import Topic, TopicStatus
|
from api.models.topic import Topic, TopicStatus
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.schemas.decision import DecisionRead
|
from api.schemas.decision import DecisionRead
|
||||||
from api.schemas.domain import DomainSummary
|
from api.schemas.domain import DomainSummary
|
||||||
from api.schemas.progress_event import ProgressEventRead
|
from api.schemas.progress_event import ProgressEventRead
|
||||||
@@ -43,38 +43,74 @@ from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
|||||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||||
from api.routers.workstreams import _workplan_index
|
from api.routers.workstreams import _workplan_index
|
||||||
|
from api.services.summary_cache import (
|
||||||
|
apply_progress_section,
|
||||||
|
fetch_summary_revision,
|
||||||
|
get_summary_cache,
|
||||||
|
register_summary_cache_invalidation,
|
||||||
|
)
|
||||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
CLOSED_WORKSTREAM_STATUSES,
|
CLOSED_WORKPLAN_STATUSES,
|
||||||
OPEN_WORKSTREAM_STATUSES,
|
OPEN_WORKPLAN_STATUSES,
|
||||||
normalize_workstream_status,
|
normalize_workplan_status,
|
||||||
)
|
)
|
||||||
from task_flow_engine import FlowEngine
|
from task_flow_engine import FlowEngine
|
||||||
|
|
||||||
router = APIRouter(prefix="/state", tags=["state"])
|
router = APIRouter(prefix="/state", tags=["state"])
|
||||||
|
|
||||||
_SUMMARY_CACHE: StateSummary | None = None
|
|
||||||
_SUMMARY_CACHE_AT: float = 0.0
|
|
||||||
_SUMMARY_TTL = 15.0
|
|
||||||
_OVERVIEW_CACHE: DashboardOverview | None = None
|
_OVERVIEW_CACHE: DashboardOverview | None = None
|
||||||
_OVERVIEW_CACHE_AT: float = 0.0
|
_OVERVIEW_CACHE_AT: float = 0.0
|
||||||
_OVERVIEW_TTL = 10.0
|
_OVERVIEW_TTL = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_cache_headers(
|
||||||
|
response: Response,
|
||||||
|
*,
|
||||||
|
cache_status: str,
|
||||||
|
revision: str,
|
||||||
|
) -> None:
|
||||||
|
response.headers["X-StateHub-Cache"] = cache_status
|
||||||
|
response.headers["X-StateHub-Revision"] = revision
|
||||||
|
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=StateSummary)
|
@router.get("/summary", response_model=StateSummary)
|
||||||
async def get_summary(
|
async def get_summary(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
refresh: bool = False,
|
||||||
) -> StateSummary:
|
) -> StateSummary:
|
||||||
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
|
revision = await fetch_summary_revision(session)
|
||||||
no_cache = "no-cache" in request.headers.get("cache-control", "")
|
revision_token = revision.combined_fingerprint()
|
||||||
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
|
force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "")
|
||||||
response.headers["X-StateHub-Cache"] = "hit"
|
|
||||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
cache = get_summary_cache()
|
||||||
return _SUMMARY_CACHE
|
cache_status, cached = cache.resolve(revision, force_refresh=force_refresh)
|
||||||
response.headers["X-StateHub-Cache"] = "miss"
|
|
||||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
if cache_status == "hit-revision" and cached is not None:
|
||||||
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
if cache_status == "progress-section" and cached is not None:
|
||||||
|
result = await apply_progress_section(session, cached, revision)
|
||||||
|
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if cache_status == "stale" and cached is not None:
|
||||||
|
cache.schedule_refresh(revision)
|
||||||
|
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
result = await build_state_summary(session)
|
||||||
|
cache.store(result, revision)
|
||||||
|
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||||
|
"""Build the full state summary snapshot (cache miss / forced refresh)."""
|
||||||
# Run all queries sequentially on one session.
|
# Run all queries sequentially on one session.
|
||||||
# AsyncSession does not support concurrent operations (no gather on same session).
|
# AsyncSession does not support concurrent operations (no gather on same session).
|
||||||
|
|
||||||
@@ -82,7 +118,7 @@ async def get_summary(
|
|||||||
select(Topic)
|
select(Topic)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Topic.domain),
|
selectinload(Topic.domain),
|
||||||
noload(Topic.workstreams),
|
noload(Topic.workplans),
|
||||||
noload(Topic.decisions),
|
noload(Topic.decisions),
|
||||||
noload(Topic.progress_events),
|
noload(Topic.progress_events),
|
||||||
)
|
)
|
||||||
@@ -96,16 +132,16 @@ async def get_summary(
|
|||||||
if topic_ids:
|
if topic_ids:
|
||||||
topic_ws_rows = await session.execute(
|
topic_ws_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
Workstream.topic_id,
|
Workplan.topic_id,
|
||||||
Workstream.id,
|
Workplan.id,
|
||||||
Workstream.slug,
|
Workplan.slug,
|
||||||
Workstream.title,
|
Workplan.title,
|
||||||
Workstream.status,
|
Workplan.status,
|
||||||
Workstream.owner,
|
Workplan.owner,
|
||||||
Workstream.due_date,
|
Workplan.due_date,
|
||||||
)
|
)
|
||||||
.where(Workstream.topic_id.in_(topic_ids))
|
.where(Workplan.topic_id.in_(topic_ids))
|
||||||
.order_by(Workstream.created_at)
|
.order_by(Workplan.created_at)
|
||||||
)
|
)
|
||||||
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
||||||
topic_workstreams.setdefault(topic_id, []).append({
|
topic_workstreams.setdefault(topic_id, []).append({
|
||||||
@@ -136,10 +172,10 @@ async def get_summary(
|
|||||||
recent = list(recent_rows.scalars().all())
|
recent = list(recent_rows.scalars().all())
|
||||||
|
|
||||||
open_ws_rows = await session.execute(
|
open_ws_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
|
|
||||||
@@ -147,7 +183,7 @@ async def get_summary(
|
|||||||
task_per_ws: dict = {}
|
task_per_ws: dict = {}
|
||||||
task_statuses_per_ws: dict = {}
|
task_statuses_per_ws: dict = {}
|
||||||
for ws_id, tstat, cnt in await session.execute(
|
for ws_id, tstat, cnt in await session.execute(
|
||||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||||
):
|
):
|
||||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||||
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||||
@@ -157,9 +193,9 @@ async def get_summary(
|
|||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
dep_result = await session.execute(
|
dep_result = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dep_rows = list(dep_result.scalars().all())
|
dep_rows = list(dep_result.scalars().all())
|
||||||
@@ -168,16 +204,16 @@ async def get_summary(
|
|||||||
dep_ws_ids = set()
|
dep_ws_ids = set()
|
||||||
dep_task_ids = set()
|
dep_task_ids = set()
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
dep_ws_ids.add(d.from_workstream_id)
|
dep_ws_ids.add(d.from_workplan_id)
|
||||||
if d.to_workstream_id:
|
if d.to_workplan_id:
|
||||||
dep_ws_ids.add(d.to_workstream_id)
|
dep_ws_ids.add(d.to_workplan_id)
|
||||||
if d.to_task_id:
|
if d.to_task_id:
|
||||||
dep_task_ids.add(d.to_task_id)
|
dep_task_ids.add(d.to_task_id)
|
||||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
extra_rows = await session.execute(
|
extra_rows = await session.execute(
|
||||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||||
)
|
)
|
||||||
for w in extra_rows.scalars():
|
for w in extra_rows.scalars():
|
||||||
ws_lookup[w.id] = w
|
ws_lookup[w.id] = w
|
||||||
@@ -189,7 +225,7 @@ async def get_summary(
|
|||||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||||
dep_id=d.id,
|
dep_id=d.id,
|
||||||
@@ -230,9 +266,9 @@ async def get_summary(
|
|||||||
"workstation": w.status,
|
"workstation": w.status,
|
||||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||||
for d in dep_rows
|
for d in dep_rows
|
||||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||||
@@ -246,7 +282,7 @@ async def get_summary(
|
|||||||
select(Topic.status, func.count()).group_by(Topic.status)
|
select(Topic.status, func.count()).group_by(Topic.status)
|
||||||
)}
|
)}
|
||||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||||
)}
|
)}
|
||||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Task.status, func.count()).group_by(Task.status)
|
select(Task.status, func.count()).group_by(Task.status)
|
||||||
@@ -370,11 +406,13 @@ async def get_summary(
|
|||||||
for w in open_ws
|
for w in open_ws
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
_SUMMARY_CACHE = result
|
|
||||||
_SUMMARY_CACHE_AT = time.monotonic()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
get_summary_cache().configure(build_state_summary)
|
||||||
|
register_summary_cache_invalidation()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview", response_model=DashboardOverview)
|
@router.get("/overview", response_model=DashboardOverview)
|
||||||
async def get_overview(
|
async def get_overview(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -407,7 +445,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
select(Topic)
|
select(Topic)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Topic.domain),
|
selectinload(Topic.domain),
|
||||||
noload(Topic.workstreams),
|
noload(Topic.workplans),
|
||||||
noload(Topic.decisions),
|
noload(Topic.decisions),
|
||||||
noload(Topic.progress_events),
|
noload(Topic.progress_events),
|
||||||
)
|
)
|
||||||
@@ -418,12 +456,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
topic_map = {topic.id: topic for topic in topics}
|
topic_map = {topic.id: topic for topic in topics}
|
||||||
|
|
||||||
workstream_rows = await session.execute(
|
workstream_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.order_by(
|
.order_by(
|
||||||
Workstream.planning_priority.asc().nullslast(),
|
Workplan.planning_priority.asc().nullslast(),
|
||||||
Workstream.planning_order.asc().nullslast(),
|
Workplan.planning_order.asc().nullslast(),
|
||||||
Workstream.updated_at.desc(),
|
Workplan.updated_at.desc(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
workstreams_all = list(workstream_rows.scalars().all())
|
workstreams_all = list(workstream_rows.scalars().all())
|
||||||
@@ -455,7 +493,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
task_statuses_per_ws: dict = {}
|
task_statuses_per_ws: dict = {}
|
||||||
task_totals_by_status: dict[str, int] = {}
|
task_totals_by_status: dict[str, int] = {}
|
||||||
for ws_id, task_status, count in await session.execute(
|
for ws_id, task_status, count in await session.execute(
|
||||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||||
):
|
):
|
||||||
status = status_value(task_status)
|
status = status_value(task_status)
|
||||||
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
|
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
|
||||||
@@ -467,15 +505,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
|
|
||||||
open_ws = [
|
open_ws = [
|
||||||
w for w in workstreams_all
|
w for w in workstreams_all
|
||||||
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
|
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
||||||
]
|
]
|
||||||
open_ws_ids = [w.id for w in open_ws]
|
open_ws_ids = [w.id for w in open_ws]
|
||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
dep_result = await session.execute(
|
dep_result = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dep_rows = list(dep_result.scalars().all())
|
dep_rows = list(dep_result.scalars().all())
|
||||||
@@ -490,19 +528,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
"workstation": w.status,
|
"workstation": w.status,
|
||||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
|
||||||
for d in dep_rows
|
for d in dep_rows
|
||||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
|
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status)
|
||||||
|
|
||||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Topic.status, func.count()).group_by(Topic.status)
|
select(Topic.status, func.count()).group_by(Topic.status)
|
||||||
)}
|
)}
|
||||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
select(Workplan.status, func.count()).group_by(Workplan.status)
|
||||||
)}
|
)}
|
||||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||||
select(Decision.status, func.count()).group_by(Decision.status)
|
select(Decision.status, func.count()).group_by(Decision.status)
|
||||||
@@ -631,7 +669,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||||||
workplan_rows.append(DashboardWorkplanRow(
|
workplan_rows.append(DashboardWorkplanRow(
|
||||||
id=w.id,
|
id=w.id,
|
||||||
title=w.title,
|
title=w.title,
|
||||||
status=normalize_workstream_status(w.status),
|
status=normalize_workplan_status(w.status),
|
||||||
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
|
||||||
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
||||||
workplan_filename=workplan.get("filename"),
|
workplan_filename=workplan.get("filename"),
|
||||||
@@ -695,9 +733,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
|||||||
# Active workstream counts per domain (join through topics)
|
# Active workstream counts per domain (join through topics)
|
||||||
ws_per_domain = {}
|
ws_per_domain = {}
|
||||||
for domain_id, cnt in await session.execute(
|
for domain_id, cnt in await session.execute(
|
||||||
select(Topic.domain_id, func.count(Workstream.id))
|
select(Topic.domain_id, func.count(Workplan.id))
|
||||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
.join(Workplan, Workplan.topic_id == Topic.id)
|
||||||
.where(Workstream.status.in_(["active", "blocked"]))
|
.where(Workplan.status.in_(["active", "blocked"]))
|
||||||
.group_by(Topic.domain_id)
|
.group_by(Topic.domain_id)
|
||||||
):
|
):
|
||||||
ws_per_domain[domain_id] = cnt
|
ws_per_domain[domain_id] = cnt
|
||||||
@@ -734,10 +772,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
Used by workstreams.md and dependencies.md which only need dep edges.
|
Used by workstreams.md and dependencies.md which only need dep edges.
|
||||||
"""
|
"""
|
||||||
open_ws_rows = await session.execute(
|
open_ws_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
|
|
||||||
@@ -745,9 +783,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
dep_rows = []
|
dep_rows = []
|
||||||
if open_ws_ids:
|
if open_ws_ids:
|
||||||
dep_result = await session.execute(
|
dep_result = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dep_rows = list(dep_result.scalars().all())
|
dep_rows = list(dep_result.scalars().all())
|
||||||
@@ -755,9 +793,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
dep_ws_ids: set = set()
|
dep_ws_ids: set = set()
|
||||||
dep_task_ids: set = set()
|
dep_task_ids: set = set()
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
dep_ws_ids.add(d.from_workstream_id)
|
dep_ws_ids.add(d.from_workplan_id)
|
||||||
if d.to_workstream_id:
|
if d.to_workplan_id:
|
||||||
dep_ws_ids.add(d.to_workstream_id)
|
dep_ws_ids.add(d.to_workplan_id)
|
||||||
if d.to_task_id:
|
if d.to_task_id:
|
||||||
dep_task_ids.add(d.to_task_id)
|
dep_task_ids.add(d.to_task_id)
|
||||||
|
|
||||||
@@ -765,7 +803,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||||
if extra_ids:
|
if extra_ids:
|
||||||
extra_rows = await session.execute(
|
extra_rows = await session.execute(
|
||||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
|
||||||
)
|
)
|
||||||
for w in extra_rows.scalars():
|
for w in extra_rows.scalars():
|
||||||
ws_lookup[w.id] = w
|
ws_lookup[w.id] = w
|
||||||
@@ -777,7 +815,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
|
|
||||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||||
for d in dep_rows:
|
for d in dep_rows:
|
||||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
|
||||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||||
@@ -831,7 +869,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Decision.status == DecisionStatus.resolved)
|
.where(Decision.status == DecisionStatus.resolved)
|
||||||
.where(Decision.decided_at >= cutoff)
|
.where(Decision.decided_at >= cutoff)
|
||||||
.where(Decision.workstream_id.isnot(None))
|
.where(Decision.workplan_id.isnot(None))
|
||||||
.order_by(Decision.decided_at.desc())
|
.order_by(Decision.decided_at.desc())
|
||||||
.limit(20)
|
.limit(20)
|
||||||
)
|
)
|
||||||
@@ -839,7 +877,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
open_tasks_rows = await session.execute(
|
open_tasks_rows = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Task.workstream_id == decision.workstream_id)
|
.where(Task.workplan_id == decision.workplan_id)
|
||||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
|
||||||
)
|
)
|
||||||
open_tasks = list(open_tasks_rows.scalars().all())
|
open_tasks = list(open_tasks_rows.scalars().all())
|
||||||
@@ -848,7 +886,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||||
if task.id in seen_task_ids:
|
if task.id in seen_task_ids:
|
||||||
continue
|
continue
|
||||||
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
|
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
||||||
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
||||||
steps.append(NextStep(
|
steps.append(NextStep(
|
||||||
type="resolved_decision",
|
type="resolved_decision",
|
||||||
@@ -868,13 +906,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
||||||
all_dep_rows = await session.execute(
|
all_dep_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
WorkstreamDependency.from_workstream_id,
|
WorkplanDependency.from_workplan_id,
|
||||||
WorkstreamDependency.to_workstream_id,
|
WorkplanDependency.to_workplan_id,
|
||||||
).where(WorkstreamDependency.to_workstream_id.isnot(None))
|
).where(WorkplanDependency.to_workplan_id.isnot(None))
|
||||||
)
|
)
|
||||||
all_deps = all_dep_rows.all()
|
all_deps = all_dep_rows.all()
|
||||||
|
|
||||||
# Group from_workstream_id → set of to_workstream_ids
|
# Group from_workplan_id → set of to_workplan_ids
|
||||||
dep_map: dict = {}
|
dep_map: dict = {}
|
||||||
dep_ws_ids = set()
|
dep_ws_ids = set()
|
||||||
for from_ws_id, to_ws_id in all_deps:
|
for from_ws_id, to_ws_id in all_deps:
|
||||||
@@ -886,12 +924,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
if dep_ws_ids:
|
if dep_ws_ids:
|
||||||
ws_rows = await session.execute(
|
ws_rows = await session.execute(
|
||||||
select(
|
select(
|
||||||
Workstream.id,
|
Workplan.id,
|
||||||
Workstream.status,
|
Workplan.status,
|
||||||
Workstream.title,
|
Workplan.title,
|
||||||
Workstream.slug,
|
Workplan.slug,
|
||||||
Workstream.topic_id,
|
Workplan.topic_id,
|
||||||
).where(Workstream.id.in_(dep_ws_ids))
|
).where(Workplan.id.in_(dep_ws_ids))
|
||||||
)
|
)
|
||||||
ws_info = {
|
ws_info = {
|
||||||
ws_id: {
|
ws_id: {
|
||||||
@@ -906,9 +944,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
ready_from_ws_ids = [
|
ready_from_ws_ids = [
|
||||||
from_ws_id
|
from_ws_id
|
||||||
for from_ws_id, to_ws_ids in dep_map.items()
|
for from_ws_id, to_ws_ids in dep_map.items()
|
||||||
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
|
if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES
|
||||||
and all(
|
and all(
|
||||||
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
|
normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES
|
||||||
for to_id in to_ws_ids
|
for to_id in to_ws_ids
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -918,11 +956,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
todo_rows = await session.execute(
|
todo_rows = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Task.workstream_id.in_(ready_from_ws_ids))
|
.where(Task.workplan_id.in_(ready_from_ws_ids))
|
||||||
.where(Task.status == TaskStatus.todo)
|
.where(Task.status == TaskStatus.todo)
|
||||||
)
|
)
|
||||||
for task in todo_rows.scalars().all():
|
for task in todo_rows.scalars().all():
|
||||||
todo_by_ws.setdefault(task.workstream_id, []).append(task)
|
todo_by_ws.setdefault(task.workplan_id, []).append(task)
|
||||||
|
|
||||||
for from_ws_id in ready_from_ws_ids:
|
for from_ws_id in ready_from_ws_ids:
|
||||||
from_ws = ws_info.get(from_ws_id, {})
|
from_ws = ws_info.get(from_ws_id, {})
|
||||||
@@ -956,7 +994,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
return steps
|
return steps
|
||||||
|
|
||||||
|
|
||||||
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
|
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
|
||||||
"""Get the domain slug for a workstream via its topic."""
|
"""Get the domain slug for a workstream via its topic."""
|
||||||
if ws is None or ws.topic_id is None:
|
if ws is None or ws.topic_id is None:
|
||||||
return None
|
return None
|
||||||
@@ -986,10 +1024,9 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health_check() -> dict:
|
async def health_check(session: AsyncSession = Depends(get_session)) -> dict:
|
||||||
try:
|
try:
|
||||||
async with engine.connect() as conn:
|
await session.execute(text("SELECT 1"))
|
||||||
await conn.execute(text("SELECT 1"))
|
|
||||||
return {"status": "ok", "db": "connected"}
|
return {"status": "ok", "db": "connected"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from api.database import get_session
|
|||||||
from api.models.progress_event import ProgressEvent
|
from api.models.progress_event import ProgressEvent
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.token_event import TokenEvent
|
from api.models.token_event import TokenEvent
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.task import (
|
from api.schemas.task import (
|
||||||
TaskCountRead,
|
TaskCountRead,
|
||||||
TaskCreate,
|
TaskCreate,
|
||||||
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
|
|||||||
|
|
||||||
@router.get("/", response_model=list[TaskRead])
|
@router.get("/", response_model=list[TaskRead])
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
|
workplan_id: uuid.UUID | None = None,
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
assignee: str | None = None,
|
assignee: str | None = None,
|
||||||
@@ -37,8 +38,9 @@ async def list_tasks(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Task]:
|
) -> list[Task]:
|
||||||
q = select(Task)
|
q = select(Task)
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
q = q.where(Task.workstream_id == workstream_id)
|
if scope_id:
|
||||||
|
q = q.where(Task.workplan_id == scope_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||||
if assignee:
|
if assignee:
|
||||||
@@ -60,18 +62,20 @@ async def list_tasks(
|
|||||||
|
|
||||||
@router.get("/counts", response_model=list[TaskCountRead])
|
@router.get("/counts", response_model=list[TaskCountRead])
|
||||||
async def count_tasks(
|
async def count_tasks(
|
||||||
|
workplan_id: uuid.UUID | None = None,
|
||||||
workstream_id: uuid.UUID | None = None,
|
workstream_id: uuid.UUID | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[TaskCountRead]:
|
) -> list[TaskCountRead]:
|
||||||
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||||
if workstream_id:
|
scope_id = workplan_id or workstream_id
|
||||||
q = q.where(Task.workstream_id == workstream_id)
|
if scope_id:
|
||||||
|
q = q.where(Task.workplan_id == scope_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||||
rows = await session.execute(q)
|
rows = await session.execute(q)
|
||||||
return [
|
return [
|
||||||
TaskCountRead(workstream_id=ws_id, status=task_status, count=count)
|
TaskCountRead(workplan_id=ws_id, status=task_status, count=count)
|
||||||
for ws_id, task_status, count in rows
|
for ws_id, task_status, count in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ async def create_task(
|
|||||||
task = Task(**body.model_dump())
|
task = Task(**body.model_dump())
|
||||||
session.add(task)
|
session.add(task)
|
||||||
if status_value(task.status) == "progress":
|
if status_value(task.status) == "progress":
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
task.status,
|
task.status,
|
||||||
@@ -137,7 +141,7 @@ async def bulk_status_sync(
|
|||||||
target_status = status_value(update.status)
|
target_status = status_value(update.status)
|
||||||
if update.blocking_reason is not None:
|
if update.blocking_reason is not None:
|
||||||
task.blocking_reason = update.blocking_reason
|
task.blocking_reason = update.blocking_reason
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
update.status,
|
update.status,
|
||||||
@@ -146,7 +150,7 @@ async def bulk_status_sync(
|
|||||||
)
|
)
|
||||||
event = ProgressEvent(
|
event = ProgressEvent(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
workstream_id=task.workstream_id,
|
workplan_id=task.workplan_id,
|
||||||
event_type="task_status_changed",
|
event_type="task_status_changed",
|
||||||
summary=f"Task status -> {target_status}: {task.title}",
|
summary=f"Task status -> {target_status}: {task.title}",
|
||||||
author=author,
|
author=author,
|
||||||
@@ -218,7 +222,7 @@ async def update_task(
|
|||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(task, field, value)
|
setattr(task, field, value)
|
||||||
if new_status is not None:
|
if new_status is not None:
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
transition_task_status(
|
transition_task_status(
|
||||||
task,
|
task,
|
||||||
status_update,
|
status_update,
|
||||||
@@ -247,7 +251,7 @@ async def update_task(
|
|||||||
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
||||||
# Tier 2: prorate workplan total across task count
|
# Tier 2: prorate workplan total across task count
|
||||||
count_result = await session.execute(
|
count_result = await session.execute(
|
||||||
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
|
select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id)
|
||||||
)
|
)
|
||||||
task_count = max(count_result.scalar() or 1, 1)
|
task_count = max(count_result.scalar() or 1, 1)
|
||||||
tin = token_data["workplan_tokens_in"] // task_count
|
tin = token_data["workplan_tokens_in"] // task_count
|
||||||
@@ -273,12 +277,12 @@ async def update_task(
|
|||||||
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
|
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
|
||||||
|
|
||||||
# Resolve repo_id via workstream
|
# Resolve repo_id via workstream
|
||||||
ws = await session.get(Workstream, task.workstream_id)
|
ws = await session.get(Workplan, task.workplan_id)
|
||||||
repo_id = ws.repo_id if ws else None
|
repo_id = ws.repo_id if ws else None
|
||||||
|
|
||||||
event = TokenEvent(
|
event = TokenEvent(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
workstream_id=task.workstream_id,
|
workplan_id=task.workplan_id,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
tokens_in=tin,
|
tokens_in=tin,
|
||||||
tokens_out=tout,
|
tokens_out=tout,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from api.database import get_session
|
|||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.token_event import TokenEvent
|
from api.models.token_event import TokenEvent
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.token_event import (
|
from api.schemas.token_event import (
|
||||||
RepoTokenSummary,
|
RepoTokenSummary,
|
||||||
TokenAggregateRow,
|
TokenAggregateRow,
|
||||||
@@ -102,14 +102,14 @@ def _apply_event_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
|
async def _populate_relationship_defaults(data: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
|
||||||
# Auto-populate workstream_id from task if not provided
|
# Auto-populate workstream_id from task if not provided
|
||||||
if data.get("task_id") and not data.get("workstream_id"):
|
if data.get("task_id") and not data.get("workplan_id"):
|
||||||
task = await session.get(Task, data["task_id"])
|
task = await session.get(Task, data["task_id"])
|
||||||
if task:
|
if task:
|
||||||
data["workstream_id"] = task.workstream_id
|
data["workplan_id"] = task.workplan_id
|
||||||
|
|
||||||
# Auto-populate repo_id from workstream if not provided
|
# Auto-populate repo_id from workstream if not provided
|
||||||
if data.get("workstream_id") and not data.get("repo_id"):
|
if data.get("workplan_id") and not data.get("repo_id"):
|
||||||
ws = await session.get(Workstream, data["workstream_id"])
|
ws = await session.get(Workplan, data["workplan_id"])
|
||||||
if ws and ws.repo_id:
|
if ws and ws.repo_id:
|
||||||
data["repo_id"] = ws.repo_id
|
data["repo_id"] = ws.repo_id
|
||||||
return data
|
return data
|
||||||
@@ -169,7 +169,7 @@ def _filter_query(
|
|||||||
if task_id:
|
if task_id:
|
||||||
q = q.where(TokenEvent.task_id == task_id)
|
q = q.where(TokenEvent.task_id == task_id)
|
||||||
if workstream_id:
|
if workstream_id:
|
||||||
q = q.where(TokenEvent.workstream_id == workstream_id)
|
q = q.where(TokenEvent.workplan_id == workstream_id)
|
||||||
if repo_id:
|
if repo_id:
|
||||||
q = q.where(TokenEvent.repo_id == repo_id)
|
q = q.where(TokenEvent.repo_id == repo_id)
|
||||||
if ref_type:
|
if ref_type:
|
||||||
@@ -195,7 +195,7 @@ def _filter_query(
|
|||||||
if unattributed:
|
if unattributed:
|
||||||
q = q.where(
|
q = q.where(
|
||||||
TokenEvent.repo_id.is_(None),
|
TokenEvent.repo_id.is_(None),
|
||||||
TokenEvent.workstream_id.is_(None),
|
TokenEvent.workplan_id.is_(None),
|
||||||
TokenEvent.task_id.is_(None),
|
TokenEvent.task_id.is_(None),
|
||||||
)
|
)
|
||||||
return q
|
return q
|
||||||
@@ -238,7 +238,7 @@ async def get_token_summary(
|
|||||||
uid = uuid.UUID(id)
|
uid = uuid.UUID(id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
|
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
|
||||||
q = q.where(TokenEvent.workstream_id == uid)
|
q = q.where(TokenEvent.workplan_id == uid)
|
||||||
elif scope == "repo":
|
elif scope == "repo":
|
||||||
try:
|
try:
|
||||||
uid = uuid.UUID(id)
|
uid = uuid.UUID(id)
|
||||||
@@ -297,7 +297,7 @@ async def get_tokens_by_repo(
|
|||||||
Resolution order for each event:
|
Resolution order for each event:
|
||||||
1. token_events.repo_id (direct)
|
1. token_events.repo_id (direct)
|
||||||
2. → workstreams.repo_id (via workstream_id)
|
2. → workstreams.repo_id (via workstream_id)
|
||||||
3. → task.workstream_id → workstreams.repo_id (via task_id)
|
3. → task.workplan_id → workstreams.repo_id (via task_id)
|
||||||
|
|
||||||
Only events that resolve to a repo are included.
|
Only events that resolve to a repo are included.
|
||||||
"""
|
"""
|
||||||
@@ -314,8 +314,8 @@ async def get_tokens_by_repo(
|
|||||||
)
|
)
|
||||||
events = list(events_result.scalars().all())
|
events = list(events_result.scalars().all())
|
||||||
|
|
||||||
ws_result = await session.execute(select(Workstream))
|
ws_result = await session.execute(select(Workplan))
|
||||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||||
|
|
||||||
task_result = await session.execute(select(Task))
|
task_result = await session.execute(select(Task))
|
||||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||||
@@ -326,9 +326,9 @@ async def get_tokens_by_repo(
|
|||||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||||
if e.repo_id:
|
if e.repo_id:
|
||||||
return e.repo_id
|
return e.repo_id
|
||||||
ws_id = e.workstream_id
|
ws_id = e.workplan_id
|
||||||
if not ws_id and e.task_id and e.task_id in task_map:
|
if not ws_id and e.task_id and e.task_id in task_map:
|
||||||
ws_id = task_map[e.task_id].workstream_id
|
ws_id = task_map[e.task_id].workplan_id
|
||||||
if ws_id and ws_id in ws_map:
|
if ws_id and ws_id in ws_map:
|
||||||
return ws_map[ws_id].repo_id
|
return ws_map[ws_id].repo_id
|
||||||
return None
|
return None
|
||||||
@@ -391,8 +391,8 @@ async def get_token_aggregate(
|
|||||||
)
|
)
|
||||||
events = list(events_result.scalars().all())
|
events = list(events_result.scalars().all())
|
||||||
|
|
||||||
ws_result = await session.execute(select(Workstream))
|
ws_result = await session.execute(select(Workplan))
|
||||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||||
|
|
||||||
task_result = await session.execute(select(Task))
|
task_result = await session.execute(select(Task))
|
||||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||||
@@ -403,9 +403,9 @@ async def get_token_aggregate(
|
|||||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||||
if e.repo_id:
|
if e.repo_id:
|
||||||
return e.repo_id
|
return e.repo_id
|
||||||
ws_id = e.workstream_id
|
ws_id = e.workplan_id
|
||||||
if not ws_id and e.task_id and e.task_id in task_map:
|
if not ws_id and e.task_id and e.task_id in task_map:
|
||||||
ws_id = task_map[e.task_id].workstream_id
|
ws_id = task_map[e.task_id].workplan_id
|
||||||
if ws_id and ws_id in ws_map:
|
if ws_id and ws_id in ws_map:
|
||||||
return ws_map[ws_id].repo_id
|
return ws_map[ws_id].repo_id
|
||||||
return None
|
return None
|
||||||
@@ -458,7 +458,7 @@ async def get_token_aggregate(
|
|||||||
repo = repo_map.get(rid) if rid else None
|
repo = repo_map.get(rid) if rid else None
|
||||||
add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e)
|
add(by_repo, str(rid) if rid else None, repo.slug if repo else None, e)
|
||||||
|
|
||||||
ws_id = e.workstream_id or (task_map[e.task_id].workstream_id if e.task_id in task_map else None)
|
ws_id = e.workplan_id or (task_map[e.task_id].workplan_id if e.task_id in task_map else None)
|
||||||
ws = ws_map.get(ws_id) if ws_id else None
|
ws = ws_map.get(ws_id) if ws_id else None
|
||||||
add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e)
|
add(by_workstream, str(ws_id) if ws_id else None, ws.title if ws else None, e)
|
||||||
|
|
||||||
@@ -520,7 +520,7 @@ async def get_token_quality(
|
|||||||
source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
|
source_counts[(e.measurement_kind, e.source_provider, e.source_id)] += 1
|
||||||
if e.source_provider == "task_fallback" or e.note == "heuristic":
|
if e.source_provider == "task_fallback" or e.note == "heuristic":
|
||||||
fallback_count += 1
|
fallback_count += 1
|
||||||
if e.measurement_kind == "measured" and not (e.repo_id or e.workstream_id or e.task_id):
|
if e.measurement_kind == "measured" and not (e.repo_id or e.workplan_id or e.task_id):
|
||||||
unattributed_measured_count += 1
|
unattributed_measured_count += 1
|
||||||
if e.measurement_kind == "measured" and not e.source_id:
|
if e.measurement_kind == "measured" and not e.source_id:
|
||||||
missing_provenance_count += 1
|
missing_provenance_count += 1
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async def list_topics(
|
|||||||
) -> list[Topic]:
|
) -> list[Topic]:
|
||||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||||
q = select(Topic).options(
|
q = select(Topic).options(
|
||||||
noload(Topic.workstreams),
|
noload(Topic.workplans),
|
||||||
noload(Topic.decisions),
|
noload(Topic.decisions),
|
||||||
noload(Topic.progress_events),
|
noload(Topic.progress_events),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
|
||||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||||
|
|
||||||
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
||||||
@@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
|
|||||||
|
|
||||||
async def _create_dependency(
|
async def _create_dependency(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamDependencyCreate,
|
body: WorkplanDependencyCreate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> WorkstreamDependency:
|
) -> WorkplanDependency:
|
||||||
if await session.get(Workstream, workstream_id) is None:
|
if await session.get(Workplan, workplan_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="from workplan not found")
|
raise HTTPException(status_code=404, detail="from workplan not found")
|
||||||
|
|
||||||
has_workstream_target = body.to_workstream_id is not None
|
has_workplan_target = body.to_workplan_id is not None
|
||||||
has_task_target = body.to_task_id is not None
|
has_task_target = body.to_task_id is not None
|
||||||
if has_workstream_target == has_task_target:
|
if has_workplan_target == has_task_target:
|
||||||
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
||||||
|
|
||||||
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
|
if body.to_workplan_id and await session.get(Workplan, body.to_workplan_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="target workplan not found")
|
raise HTTPException(status_code=404, detail="target workplan not found")
|
||||||
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="target task not found")
|
raise HTTPException(status_code=404, detail="target task not found")
|
||||||
if workstream_id == body.to_workstream_id:
|
if workplan_id == body.to_workplan_id:
|
||||||
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
|
raise HTTPException(status_code=422, detail="a workplan cannot depend on itself")
|
||||||
|
|
||||||
dep = WorkstreamDependency(
|
dep = WorkplanDependency(
|
||||||
from_workstream_id=workstream_id,
|
from_workplan_id=workplan_id,
|
||||||
to_workstream_id=body.to_workstream_id,
|
to_workplan_id=body.to_workplan_id,
|
||||||
to_task_id=body.to_task_id,
|
to_task_id=body.to_task_id,
|
||||||
relationship_type=body.relationship_type,
|
relationship_type=body.relationship_type,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
@@ -51,15 +51,15 @@ async def _create_dependency(
|
|||||||
|
|
||||||
async def _list_dependencies(
|
async def _list_dependencies(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> list[WorkstreamDependency]:
|
) -> list[WorkplanDependency]:
|
||||||
if await session.get(Workstream, workstream_id) is None:
|
if await session.get(Workplan, workplan_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="workplan not found")
|
raise HTTPException(status_code=404, detail="workplan not found")
|
||||||
rows = await session.execute(
|
rows = await session.execute(
|
||||||
select(WorkstreamDependency).where(
|
select(WorkplanDependency).where(
|
||||||
(WorkstreamDependency.from_workstream_id == workstream_id)
|
(WorkplanDependency.from_workplan_id == workplan_id)
|
||||||
| (WorkstreamDependency.to_workstream_id == workstream_id)
|
| (WorkplanDependency.to_workplan_id == workplan_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return list(rows.scalars().all())
|
return list(rows.scalars().all())
|
||||||
@@ -67,14 +67,14 @@ async def _list_dependencies(
|
|||||||
|
|
||||||
async def _delete_dependency(
|
async def _delete_dependency(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
dep_id: uuid.UUID,
|
dep_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
dep = await session.get(WorkstreamDependency, dep_id)
|
dep = await session.get(WorkplanDependency, dep_id)
|
||||||
if dep is None:
|
if dep is None:
|
||||||
raise HTTPException(status_code=404, detail="dependency not found")
|
raise HTTPException(status_code=404, detail="dependency not found")
|
||||||
if dep.from_workstream_id != workstream_id:
|
if dep.from_workplan_id != workplan_id:
|
||||||
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
|
raise HTTPException(status_code=403, detail="dependency does not belong to this workplan")
|
||||||
await session.delete(dep)
|
await session.delete(dep)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -82,17 +82,17 @@ async def _delete_dependency(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{workstream_id}/dependencies/",
|
"/{workstream_id}/dependencies/",
|
||||||
response_model=WorkstreamDependencyRead,
|
response_model=WorkplanDependencyRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_dependency(
|
async def create_dependency(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
body: WorkstreamDependencyCreate,
|
body: WorkplanDependencyCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkstreamDependency:
|
) -> WorkplanDependency:
|
||||||
"""Record that workstream_id depends on another workstream or a task."""
|
"""Record that workstream_id depends on another workplan or a task."""
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -100,33 +100,33 @@ async def create_dependency(
|
|||||||
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
|
interface_key=_legacy_key("POST", "/workstreams/{workstream_id}/dependencies/"),
|
||||||
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
||||||
)
|
)
|
||||||
return await _create_dependency(workstream_id=workstream_id, body=body, session=session)
|
return await _create_dependency(workplan_id=workstream_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.post(
|
@workplan_router.post(
|
||||||
"/{workplan_id}/dependencies/",
|
"/{workplan_id}/dependencies/",
|
||||||
response_model=WorkstreamDependencyRead,
|
response_model=WorkplanDependencyRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_workplan_dependency(
|
async def create_workplan_dependency(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamDependencyCreate,
|
body: WorkplanDependencyCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> WorkstreamDependency:
|
) -> WorkplanDependency:
|
||||||
return await _create_dependency(workstream_id=workplan_id, body=body, session=session)
|
return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{workstream_id}/dependencies/",
|
"/{workstream_id}/dependencies/",
|
||||||
response_model=list[WorkstreamDependencyRead],
|
response_model=list[WorkplanDependencyRead],
|
||||||
)
|
)
|
||||||
async def list_dependencies(
|
async def list_dependencies(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[WorkstreamDependency]:
|
) -> list[WorkplanDependency]:
|
||||||
"""Return all dependency edges touching this workstream (both directions)."""
|
"""Return all dependency edges touching this workplan (both directions)."""
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -134,18 +134,18 @@ async def list_dependencies(
|
|||||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
|
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}/dependencies/"),
|
||||||
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
replacement_ref="/workplans/{workplan_id}/dependencies/",
|
||||||
)
|
)
|
||||||
return await _list_dependencies(workstream_id=workstream_id, session=session)
|
return await _list_dependencies(workplan_id=workstream_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.get(
|
@workplan_router.get(
|
||||||
"/{workplan_id}/dependencies/",
|
"/{workplan_id}/dependencies/",
|
||||||
response_model=list[WorkstreamDependencyRead],
|
response_model=list[WorkplanDependencyRead],
|
||||||
)
|
)
|
||||||
async def list_workplan_dependencies(
|
async def list_workplan_dependencies(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[WorkstreamDependency]:
|
) -> list[WorkplanDependency]:
|
||||||
return await _list_dependencies(workstream_id=workplan_id, session=session)
|
return await _list_dependencies(workplan_id=workplan_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -167,7 +167,7 @@ async def delete_dependency(
|
|||||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
|
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}/dependencies/{dep_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
|
replacement_ref="/workplans/{workplan_id}/dependencies/{dep_id}",
|
||||||
)
|
)
|
||||||
await _delete_dependency(workstream_id=workstream_id, dep_id=dep_id, session=session)
|
await _delete_dependency(workplan_id=workstream_id, dep_id=dep_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.delete(
|
@workplan_router.delete(
|
||||||
@@ -179,4 +179,4 @@ async def delete_workplan_dependency(
|
|||||||
dep_id: uuid.UUID,
|
dep_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
await _delete_dependency(workstream_id=workplan_id, dep_id=dep_id, session=session)
|
await _delete_dependency(workplan_id=workplan_id, dep_id=dep_id, session=session)
|
||||||
@@ -15,21 +15,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from api.database import get_session
|
from api.database import get_session
|
||||||
from api.events import EventEnvelope, publish_event
|
from api.events import EventEnvelope, publish_event
|
||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.workstream import (
|
from api.schemas.workplan import (
|
||||||
WorkstreamCreate,
|
WorkplanCreate,
|
||||||
WorkstreamRead,
|
WorkplanRead,
|
||||||
WorkstreamUpdate,
|
WorkplanUpdate,
|
||||||
)
|
)
|
||||||
from api.services.lifecycle import transition_workstream_status
|
from api.services.lifecycle import transition_workplan_status
|
||||||
from api.services.legacy_meter import (
|
from api.services.legacy_meter import (
|
||||||
LegacyUsageIdentity,
|
LegacyUsageIdentity,
|
||||||
identity_from_request,
|
identity_from_request,
|
||||||
record_legacy_usage,
|
record_legacy_usage,
|
||||||
)
|
)
|
||||||
from api.workplan_status import (
|
from api.workplan_status import (
|
||||||
is_supported_workstream_status,
|
is_supported_workplan_status,
|
||||||
normalize_workstream_status,
|
normalize_workplan_status,
|
||||||
ready_review_status,
|
ready_review_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ async def _meter_legacy_event(
|
|||||||
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
|
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def _list_workstreams(
|
async def _list_workplans(
|
||||||
*,
|
*,
|
||||||
topic_id: uuid.UUID | None,
|
topic_id: uuid.UUID | None,
|
||||||
repo_id: uuid.UUID | None,
|
repo_id: uuid.UUID | None,
|
||||||
@@ -147,27 +147,27 @@ async def _list_workstreams(
|
|||||||
owner: str | None,
|
owner: str | None,
|
||||||
slug: str | None,
|
slug: str | None,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> list[Workstream]:
|
) -> list[Workplan]:
|
||||||
q = select(Workstream)
|
q = select(Workplan)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
q = q.where(Workstream.topic_id == topic_id)
|
q = q.where(Workplan.topic_id == topic_id)
|
||||||
if repo_id:
|
if repo_id:
|
||||||
q = q.where(Workstream.repo_id == repo_id)
|
q = q.where(Workplan.repo_id == repo_id)
|
||||||
if repo_goal_id:
|
if repo_goal_id:
|
||||||
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
q = q.where(Workplan.repo_goal_id == repo_goal_id)
|
||||||
if status_filter:
|
if status_filter:
|
||||||
normalised_status = normalize_workstream_status(status_filter)
|
normalised_status = normalize_workplan_status(status_filter)
|
||||||
if not is_supported_workstream_status(status_filter):
|
if not is_supported_workplan_status(status_filter):
|
||||||
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
|
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
|
||||||
q = q.where(Workstream.status == normalised_status)
|
q = q.where(Workplan.status == normalised_status)
|
||||||
if owner:
|
if owner:
|
||||||
q = q.where(Workstream.owner == owner)
|
q = q.where(Workplan.owner == owner)
|
||||||
if slug:
|
if slug:
|
||||||
q = q.where(Workstream.slug == slug)
|
q = q.where(Workplan.slug == slug)
|
||||||
q = q.order_by(
|
q = q.order_by(
|
||||||
Workstream.planning_priority.asc().nullslast(),
|
Workplan.planning_priority.asc().nullslast(),
|
||||||
Workstream.planning_order.asc().nullslast(),
|
Workplan.planning_order.asc().nullslast(),
|
||||||
Workstream.updated_at.desc(),
|
Workplan.updated_at.desc(),
|
||||||
)
|
)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
for path in sorted(directory.glob("*.md")):
|
for path in sorted(directory.glob("*.md")):
|
||||||
data = _frontmatter(path)
|
data = _frontmatter(path)
|
||||||
workstream_id = data.get("state_hub_workstream_id")
|
workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
|
||||||
if not workstream_id:
|
if not workplan_id:
|
||||||
continue
|
continue
|
||||||
file_status = normalize_workstream_status(data.get("status", ""))
|
file_status = normalize_workplan_status(data.get("status", ""))
|
||||||
review = (
|
review = (
|
||||||
ready_review_status(
|
ready_review_status(
|
||||||
root,
|
root,
|
||||||
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
|||||||
if file_status == "ready"
|
if file_status == "ready"
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
index[str(workstream_id)] = {
|
index[str(workplan_id)] = {
|
||||||
"filename": path.name,
|
"filename": path.name,
|
||||||
"relative_path": str(path.relative_to(root)),
|
"relative_path": str(path.relative_to(root)),
|
||||||
"repo_slug": repo.slug,
|
"repo_slug": repo.slug,
|
||||||
@@ -287,79 +287,79 @@ async def _workplan_index(
|
|||||||
return _INDEX_CACHE
|
return _INDEX_CACHE
|
||||||
|
|
||||||
|
|
||||||
async def _create_workstream(
|
async def _create_workplan(
|
||||||
*,
|
*,
|
||||||
body: WorkstreamCreate,
|
body: WorkplanCreate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = Workstream(**body.model_dump())
|
wp = Workplan(**body.model_dump())
|
||||||
session.add(ws)
|
session.add(wp)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(wp)
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _get_workstream(
|
async def _get_workplan(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
wp = await session.get(Workplan, workplan_id)
|
||||||
if ws is None:
|
if wp is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _update_workstream(
|
async def _update_workplan(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamUpdate,
|
body: WorkplanUpdate,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
wp = await session.get(Workplan, workplan_id)
|
||||||
if ws is None:
|
if wp is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
update_data = body.model_dump(exclude_unset=True)
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
status_update = update_data.pop("status", None)
|
status_update = update_data.pop("status", None)
|
||||||
prev_status = ws.status
|
prev_status = wp.status
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(ws, field, value)
|
setattr(wp, field, value)
|
||||||
if status_update is not None:
|
if status_update is not None:
|
||||||
transition_workstream_status(ws, status_update)
|
transition_workplan_status(wp, status_update)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(wp)
|
||||||
|
|
||||||
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
|
if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
|
||||||
await _publish_completion_events(ws, session)
|
await _publish_completion_events(wp, session)
|
||||||
|
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _archive_workstream(
|
async def _archive_workplan(
|
||||||
*,
|
*,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
ws = await session.get(Workstream, workstream_id)
|
wp = await session.get(Workplan, workplan_id)
|
||||||
if ws is None:
|
if wp is None:
|
||||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||||
transition_workstream_status(ws, "archived")
|
transition_workplan_status(wp, "archived")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(wp)
|
||||||
return ws
|
return wp
|
||||||
|
|
||||||
|
|
||||||
async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None:
|
async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None:
|
||||||
workplan_envelope = EventEnvelope.new(
|
workplan_envelope = EventEnvelope.new(
|
||||||
_COMPLETED_WORKPLAN_EVENT,
|
_COMPLETED_WORKPLAN_EVENT,
|
||||||
attributes={
|
attributes={
|
||||||
"workplan_id": str(ws.id),
|
"workplan_id": str(wp.id),
|
||||||
"legacy_workstream_id": str(ws.id),
|
"legacy_workstream_id": str(wp.id),
|
||||||
"slug": ws.slug,
|
"slug": wp.slug,
|
||||||
"title": ws.title,
|
"title": wp.title,
|
||||||
"topic_id": str(ws.topic_id),
|
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
"repo_id": str(wp.repo_id) if wp.repo_id else None,
|
||||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
|
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
|
||||||
@@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N
|
|||||||
legacy_envelope = EventEnvelope.new(
|
legacy_envelope = EventEnvelope.new(
|
||||||
_COMPLETED_WORKSTREAM_EVENT,
|
_COMPLETED_WORKSTREAM_EVENT,
|
||||||
attributes={
|
attributes={
|
||||||
"workstream_id": str(ws.id),
|
"workstream_id": str(wp.id),
|
||||||
"slug": ws.slug,
|
"slug": wp.slug,
|
||||||
"title": ws.title,
|
"title": wp.title,
|
||||||
"topic_id": str(ws.topic_id),
|
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
"repo_id": str(wp.repo_id) if wp.repo_id else None,
|
||||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
|
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorkstreamRead])
|
@router.get("/", response_model=list[WorkplanRead])
|
||||||
async def list_workstreams(
|
async def list_workstreams(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -394,7 +394,7 @@ async def list_workstreams(
|
|||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Workstream]:
|
) -> list[Workplan]:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -402,7 +402,7 @@ async def list_workstreams(
|
|||||||
interface_key=_legacy_key("GET", "/workstreams/"),
|
interface_key=_legacy_key("GET", "/workstreams/"),
|
||||||
replacement_ref="/workplans/",
|
replacement_ref="/workplans/",
|
||||||
)
|
)
|
||||||
return await _list_workstreams(
|
return await _list_workplans(
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
repo_goal_id=repo_goal_id,
|
repo_goal_id=repo_goal_id,
|
||||||
@@ -413,7 +413,7 @@ async def list_workstreams(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.get("/", response_model=list[WorkstreamRead])
|
@workplan_router.get("/", response_model=list[WorkplanRead])
|
||||||
async def list_workplans(
|
async def list_workplans(
|
||||||
topic_id: uuid.UUID | None = None,
|
topic_id: uuid.UUID | None = None,
|
||||||
repo_id: uuid.UUID | None = None,
|
repo_id: uuid.UUID | None = None,
|
||||||
@@ -422,8 +422,8 @@ async def list_workplans(
|
|||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> list[Workstream]:
|
) -> list[Workplan]:
|
||||||
return await _list_workstreams(
|
return await _list_workplans(
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
repo_goal_id=repo_goal_id,
|
repo_goal_id=repo_goal_id,
|
||||||
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
|
|||||||
return await _workplan_index(refresh=refresh, session=session)
|
return await _workplan_index(refresh=refresh, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_workstream(
|
async def create_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
body: WorkstreamCreate,
|
body: WorkplanCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -473,24 +473,24 @@ async def create_workstream(
|
|||||||
interface_key=_legacy_key("POST", "/workstreams/"),
|
interface_key=_legacy_key("POST", "/workstreams/"),
|
||||||
replacement_ref="/workplans/",
|
replacement_ref="/workplans/",
|
||||||
)
|
)
|
||||||
return await _create_workstream(body=body, session=session)
|
return await _create_workplan(body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_workplan(
|
async def create_workplan(
|
||||||
body: WorkstreamCreate,
|
body: WorkplanCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _create_workstream(body=body, session=session)
|
return await _create_workplan(body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{workstream_id}", response_model=WorkstreamRead)
|
@router.get("/{workstream_id}", response_model=WorkplanRead)
|
||||||
async def get_workstream(
|
async def get_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -498,25 +498,25 @@ async def get_workstream(
|
|||||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
|
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}",
|
replacement_ref="/workplans/{workplan_id}",
|
||||||
)
|
)
|
||||||
return await _get_workstream(workstream_id=workstream_id, session=session)
|
return await _get_workplan(workplan_id=workstream_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead)
|
@workplan_router.get("/{workplan_id}", response_model=WorkplanRead)
|
||||||
async def get_workplan(
|
async def get_workplan(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _get_workstream(workstream_id=workplan_id, session=session)
|
return await _get_workplan(workplan_id=workplan_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
|
@router.patch("/{workstream_id}", response_model=WorkplanRead)
|
||||||
async def update_workstream(
|
async def update_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
body: WorkstreamUpdate,
|
body: WorkplanUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -524,25 +524,25 @@ async def update_workstream(
|
|||||||
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
|
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}",
|
replacement_ref="/workplans/{workplan_id}",
|
||||||
)
|
)
|
||||||
return await _update_workstream(workstream_id=workstream_id, body=body, session=session)
|
return await _update_workplan(workplan_id=workstream_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead)
|
@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead)
|
||||||
async def update_workplan(
|
async def update_workplan(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
body: WorkstreamUpdate,
|
body: WorkplanUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
|
return await _update_workplan(workplan_id=workplan_id, body=body, session=session)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
|
@router.delete("/{workstream_id}", response_model=WorkplanRead)
|
||||||
async def archive_workstream(
|
async def archive_workstream(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
workstream_id: uuid.UUID,
|
workstream_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
await _meter_legacy_route(
|
await _meter_legacy_route(
|
||||||
session=session,
|
session=session,
|
||||||
request=request,
|
request=request,
|
||||||
@@ -550,12 +550,12 @@ async def archive_workstream(
|
|||||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
|
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
|
||||||
replacement_ref="/workplans/{workplan_id}",
|
replacement_ref="/workplans/{workplan_id}",
|
||||||
)
|
)
|
||||||
return await _archive_workstream(workstream_id=workstream_id, session=session)
|
return await _archive_workplan(workplan_id=workstream_id, session=session)
|
||||||
|
|
||||||
|
|
||||||
@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead)
|
@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead)
|
||||||
async def archive_workplan(
|
async def archive_workplan(
|
||||||
workplan_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Workstream:
|
) -> Workplan:
|
||||||
return await _archive_workstream(workstream_id=workplan_id, session=session)
|
return await _archive_workplan(workplan_id=workplan_id, session=session)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
||||||
|
from api.schemas.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead
|
||||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||||
@@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||||
|
"WorkplanCreate", "WorkplanUpdate", "WorkplanRead",
|
||||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
from hub_core.schemas.capability import (
|
from hub_core.schemas.capability import (
|
||||||
CapabilityRequestDispute,
|
CapabilityRequestDispute,
|
||||||
@@ -23,20 +23,29 @@ class CapabilityRequestCreate(BaseModel):
|
|||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
requesting_domain: str # slug, resolved to domain_id in router
|
requesting_domain: str # slug, resolved to domain_id in router
|
||||||
requesting_agent: str
|
requesting_agent: str
|
||||||
requesting_workstream_id: uuid.UUID | None = None
|
requesting_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("requesting_workplan_id", "requesting_workstream_id"),
|
||||||
|
)
|
||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestAccept(BaseModel):
|
class CapabilityRequestAccept(BaseModel):
|
||||||
fulfilling_agent: str
|
fulfilling_agent: str
|
||||||
fulfilling_workstream_id: uuid.UUID | None = None
|
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestPatch(BaseModel):
|
class CapabilityRequestPatch(BaseModel):
|
||||||
catalog_entry_id: uuid.UUID | None = None
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
fulfilling_workstream_id: uuid.UUID | None = None
|
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRequestReroute(BaseModel):
|
class CapabilityRequestReroute(BaseModel):
|
||||||
@@ -57,10 +66,10 @@ class CapabilityRequestRead(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
requesting_domain_slug: str
|
requesting_domain_slug: str
|
||||||
requesting_agent: str
|
requesting_agent: str
|
||||||
requesting_workstream_id: uuid.UUID | None = None
|
requesting_workplan_id: uuid.UUID | None = None
|
||||||
fulfilling_domain_slug: str | None = None
|
fulfilling_domain_slug: str | None = None
|
||||||
fulfilling_agent: str | None = None
|
fulfilling_agent: str | None = None
|
||||||
fulfilling_workstream_id: uuid.UUID | None = None
|
fulfilling_workplan_id: uuid.UUID | None = None
|
||||||
blocking_task_id: uuid.UUID | None = None
|
blocking_task_id: uuid.UUID | None = None
|
||||||
catalog_entry_id: uuid.UUID | None = None
|
catalog_entry_id: uuid.UUID | None = None
|
||||||
resolution_note: str | None = None
|
resolution_note: str | None = None
|
||||||
@@ -73,3 +82,13 @@ class CapabilityRequestRead(BaseModel):
|
|||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def requesting_workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.requesting_workplan_id
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def fulfilling_workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.fulfilling_workplan_id
|
||||||
|
|||||||
43
api/schemas/compat.py
Normal file
43
api/schemas/compat.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Shared Pydantic field helpers for workplan / workstream compatibility."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, Field, computed_field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
def workplan_id_field(*, default: uuid.UUID | None = None) -> uuid.UUID | None:
|
||||||
|
return Field(
|
||||||
|
default=default,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanIdCompatMixin:
|
||||||
|
"""Accept ``workplan_id`` or legacy ``workstream_id`` on input; emit both on output."""
|
||||||
|
|
||||||
|
workplan_id: uuid.UUID = workplan_id_field()
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID:
|
||||||
|
return self.workplan_id
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanIdCreateMixin:
|
||||||
|
workplan_id: uuid.UUID | None = workplan_id_field(default=None)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _require_workplan_id(self):
|
||||||
|
if self.workplan_id is None:
|
||||||
|
raise ValueError("workplan_id is required")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalWorkplanIdCompatMixin:
|
||||||
|
workplan_id: uuid.UUID | None = workplan_id_field(default=None)
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.workplan_id
|
||||||
49
api/schemas/consistency_sweep.py
Normal file
49
api/schemas/consistency_sweep.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConsistencySweepIssueSummary(BaseModel):
|
||||||
|
fail: int = 0
|
||||||
|
automation_error: int = 0
|
||||||
|
warn: int = 0
|
||||||
|
info: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ConsistencySweepRepoResult(BaseModel):
|
||||||
|
repo_slug: str
|
||||||
|
repo_path: str
|
||||||
|
result: str
|
||||||
|
summary: ConsistencySweepIssueSummary
|
||||||
|
fixes_applied: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsistencySweepRemoteAllGenerate(BaseModel):
|
||||||
|
max_seconds: int = Field(
|
||||||
|
default=300,
|
||||||
|
ge=0,
|
||||||
|
le=3600,
|
||||||
|
description="Wall-clock budget for the remote-all sweep (0 disables)",
|
||||||
|
)
|
||||||
|
source: str = Field(
|
||||||
|
default="api",
|
||||||
|
description="Runner label stored on progress events (local-timer, activity-core, api)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsistencySweepRemoteAllRun(BaseModel):
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime
|
||||||
|
max_seconds: int
|
||||||
|
source: str
|
||||||
|
exit_code: int
|
||||||
|
automation_error: bool = False
|
||||||
|
lock_skipped: bool
|
||||||
|
repos_processed: list[ConsistencySweepRepoResult] = Field(default_factory=list)
|
||||||
|
skipped_clean: list[str] = Field(default_factory=list)
|
||||||
|
skipped_missing: list[str] = Field(default_factory=list)
|
||||||
|
skipped_budget: list[str] = Field(default_factory=list)
|
||||||
|
progress_event_id: uuid.UUID | None = None
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
from api.models.contribution import ContributionStatus, ContributionType
|
from api.models.contribution import ContributionStatus, ContributionType
|
||||||
|
|
||||||
@@ -14,7 +14,10 @@ class ContributionCreate(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
body_path: str | None = None
|
body_path: str | None = None
|
||||||
related_topic_id: uuid.UUID | None = None
|
related_topic_id: uuid.UUID | None = None
|
||||||
related_workstream_id: uuid.UUID | None = None
|
related_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("related_workplan_id", "related_workstream_id"),
|
||||||
|
)
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
@@ -36,10 +39,15 @@ class ContributionRead(BaseModel):
|
|||||||
status: ContributionStatus
|
status: ContributionStatus
|
||||||
body_path: str | None = None
|
body_path: str | None = None
|
||||||
related_topic_id: uuid.UUID | None = None
|
related_topic_id: uuid.UUID | None = None
|
||||||
related_workstream_id: uuid.UUID | None = None
|
related_workplan_id: uuid.UUID | None = None
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
submitted_at: datetime | None = None
|
submitted_at: datetime | None = None
|
||||||
resolved_at: datetime | None = None
|
resolved_at: datetime | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def related_workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.related_workplan_id
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ from datetime import datetime
|
|||||||
from pydantic import BaseModel, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, model_validator
|
||||||
|
|
||||||
from api.models.decision import DecisionStatus, DecisionType
|
from api.models.decision import DecisionStatus, DecisionType
|
||||||
|
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||||
|
from pydantic import AliasChoices, Field
|
||||||
|
|
||||||
|
|
||||||
class DecisionCreate(BaseModel):
|
class DecisionCreate(BaseModel):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
decision_type: DecisionType = DecisionType.pending
|
decision_type: DecisionType = DecisionType.pending
|
||||||
@@ -20,9 +25,9 @@ class DecisionCreate(BaseModel):
|
|||||||
escalation_note: str | None = None
|
escalation_note: str | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
def topic_or_workplan_required(self) -> "DecisionCreate":
|
||||||
if self.topic_id is None and self.workstream_id is None:
|
if self.topic_id is None and self.workplan_id is None:
|
||||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
raise ValueError("At least one of topic_id or workplan_id must be set")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel):
|
|||||||
superseded_by: uuid.UUID | None = None
|
superseded_by: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class DecisionRead(BaseModel):
|
class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
decision_type: DecisionType
|
decision_type: DecisionType
|
||||||
@@ -61,4 +65,4 @@ class DecisionRead(BaseModel):
|
|||||||
escalation_note: str | None = None
|
escalation_note: str | None = None
|
||||||
superseded_by: uuid.UUID | None = None
|
superseded_by: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
|
||||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
||||||
@@ -21,7 +21,12 @@ class ExecutionIntentUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ExecutionIntentRead(BaseModel):
|
class ExecutionIntentRead(BaseModel):
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID:
|
||||||
|
return self.workplan_id
|
||||||
execution_state: ExecutionState
|
execution_state: ExecutionState
|
||||||
launch_mode: LaunchMode
|
launch_mode: LaunchMode
|
||||||
concurrency_mode: ConcurrencyMode
|
concurrency_mode: ConcurrencyMode
|
||||||
@@ -31,7 +36,7 @@ class ExecutionIntentRead(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class WorkplanQueueItem(BaseModel):
|
class WorkplanQueueItem(BaseModel):
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
@@ -45,13 +50,18 @@ class WorkplanQueueItem(BaseModel):
|
|||||||
execution_group: str | None = None
|
execution_group: str | None = None
|
||||||
scheduled_for: datetime | None = None
|
scheduled_for: datetime | None = None
|
||||||
eligible: bool
|
eligible: bool
|
||||||
blocked_by_workstream_ids: list[uuid.UUID] = Field(default_factory=list)
|
blocked_by_workplan_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def blocked_by_workstream_ids(self) -> list[uuid.UUID]:
|
||||||
|
return self.blocked_by_workplan_ids
|
||||||
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
|
blocked_by_task_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||||
sort_key: list[str | int] = Field(default_factory=list)
|
sort_key: list[str | int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class LaunchRequestCreate(BaseModel):
|
class LaunchRequestCreate(BaseModel):
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id"))
|
||||||
requested_by: str = "dashboard"
|
requested_by: str = "dashboard"
|
||||||
requested_actor: str | None = None
|
requested_actor: str | None = None
|
||||||
launch_mode: LaunchMode = "queued"
|
launch_mode: LaunchMode = "queued"
|
||||||
@@ -67,10 +77,15 @@ class LaunchRequestCreate(BaseModel):
|
|||||||
class LaunchRequestRead(BaseModel):
|
class LaunchRequestRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
workstream_id: uuid.UUID
|
workplan_id: uuid.UUID
|
||||||
requested_by: str
|
requested_by: str
|
||||||
requested_actor: str | None = None
|
requested_actor: str | None = None
|
||||||
launch_mode: LaunchMode
|
launch_mode: LaunchMode
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID:
|
||||||
|
return self.workplan_id
|
||||||
concurrency_mode: ConcurrencyMode
|
concurrency_mode: ConcurrencyMode
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from api.models.extension_point import EPStatus
|
from api.models.extension_point import EPStatus
|
||||||
|
|
||||||
@@ -18,7 +18,10 @@ class EPCreate(BaseModel):
|
|||||||
status: EPStatus = EPStatus.open
|
status: EPStatus = EPStatus.open
|
||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EPUpdate(BaseModel):
|
class EPUpdate(BaseModel):
|
||||||
@@ -29,7 +32,10 @@ class EPUpdate(BaseModel):
|
|||||||
ep_type: str | None = None
|
ep_type: str | None = None
|
||||||
status: EPStatus | None = None
|
status: EPStatus | None = None
|
||||||
priority: str | None = None
|
priority: str | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EPRead(BaseModel):
|
class EPRead(BaseModel):
|
||||||
@@ -45,6 +51,10 @@ class EPRead(BaseModel):
|
|||||||
status: EPStatus
|
status: EPStatus
|
||||||
priority: str
|
priority: str
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.workplan_id
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import uuid
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from fastapi import HTTPException
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from api.classification import validate_classification
|
||||||
from hub_core.schemas.managed_repo import (
|
from hub_core.schemas.managed_repo import (
|
||||||
RepoCreate as CoreRepoCreate,
|
RepoCreate as CoreRepoCreate,
|
||||||
RepoPathRegister,
|
RepoPathRegister,
|
||||||
@@ -11,11 +13,73 @@ from hub_core.schemas.managed_repo import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RepoCreate(CoreRepoCreate):
|
class ClassificationFields(BaseModel):
|
||||||
|
category: str | None = None
|
||||||
|
secondary_domains: list[str] | None = None
|
||||||
|
capability_tags: list[str] | None = None
|
||||||
|
business_stake: list[str] | None = None
|
||||||
|
business_mechanics: list[str] | None = None
|
||||||
|
classified_at: date | None = None
|
||||||
|
classified_by: str | None = None
|
||||||
|
standard_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def classification_fields_set(data: dict[str, Any]) -> bool:
|
||||||
|
keys = (
|
||||||
|
"category",
|
||||||
|
"secondary_domains",
|
||||||
|
"capability_tags",
|
||||||
|
"business_stake",
|
||||||
|
"business_mechanics",
|
||||||
|
"classified_at",
|
||||||
|
"classified_by",
|
||||||
|
"standard_version",
|
||||||
|
)
|
||||||
|
return any(data.get(key) is not None for key in keys)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_repo_classification_fields(
|
||||||
|
*,
|
||||||
|
domain_slug: str,
|
||||||
|
fields: dict[str, Any],
|
||||||
|
require_complete: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate classification fields and return normalized values for persistence."""
|
||||||
|
if not classification_fields_set(fields) and not require_complete:
|
||||||
|
return fields
|
||||||
|
|
||||||
|
block = {
|
||||||
|
"category": fields.get("category"),
|
||||||
|
"domain": domain_slug,
|
||||||
|
"secondary_domains": fields.get("secondary_domains") or [],
|
||||||
|
"capability_tags": fields.get("capability_tags") or [],
|
||||||
|
"business_stake": fields.get("business_stake") or [],
|
||||||
|
"business_mechanics": fields.get("business_mechanics") or [],
|
||||||
|
}
|
||||||
|
if require_complete or fields.get("category") is not None:
|
||||||
|
if block["category"] is None:
|
||||||
|
raise HTTPException(status_code=422, detail="`category` is required when classification is provided")
|
||||||
|
if classification_fields_set(fields) and block["category"] is not None:
|
||||||
|
errors, warnings = validate_classification(block)
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings})
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class RepoCreate(CoreRepoCreate, ClassificationFields):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_classification_on_create(self) -> "RepoCreate":
|
||||||
|
validate_repo_classification_fields(
|
||||||
|
domain_slug=self.domain_slug,
|
||||||
|
fields=self.model_dump(),
|
||||||
|
require_complete=classification_fields_set(self.model_dump()),
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
class RepoUpdate(BaseModel):
|
|
||||||
|
class RepoUpdate(ClassificationFields):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
local_path: str | None = None
|
local_path: str | None = None
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
@@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel):
|
|||||||
stderr: str = ""
|
stderr: str = ""
|
||||||
|
|
||||||
|
|
||||||
class RepoRead(CoreRepoRead):
|
class RepoRead(CoreRepoRead, ClassificationFields):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
sbom_source: str | None = None
|
sbom_source: str | None = None
|
||||||
last_sbom_at: datetime | None = None
|
last_sbom_at: datetime | None = None
|
||||||
@@ -59,13 +123,17 @@ class DispatchTask(BaseModel):
|
|||||||
needs_human: bool
|
needs_human: bool
|
||||||
|
|
||||||
|
|
||||||
class DispatchWorkstream(BaseModel):
|
class DispatchWorkplan(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
pending_tasks: list[DispatchTask]
|
pending_tasks: list[DispatchTask]
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy alias
|
||||||
|
DispatchWorkstream = DispatchWorkplan
|
||||||
|
|
||||||
|
|
||||||
class PendingInterfaceChange(BaseModel):
|
class PendingInterfaceChange(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
title: str
|
title: str
|
||||||
@@ -90,13 +158,17 @@ class ScopeIssueDetail(BaseModel):
|
|||||||
class RepoDispatch(BaseModel):
|
class RepoDispatch(BaseModel):
|
||||||
repo_slug: str
|
repo_slug: str
|
||||||
active_goal: dict[str, Any] | None
|
active_goal: dict[str, Any] | None
|
||||||
active_workstreams: list[DispatchWorkstream]
|
active_workplans: list[DispatchWorkplan]
|
||||||
human_interventions: list[DispatchTask]
|
human_interventions: list[DispatchTask]
|
||||||
pending_interface_changes: list[PendingInterfaceChange]
|
pending_interface_changes: list[PendingInterfaceChange]
|
||||||
scope_needs_review: bool
|
scope_needs_review: bool
|
||||||
scope_issue_details: list[ScopeIssueDetail]
|
scope_issue_details: list[ScopeIssueDetail]
|
||||||
last_state_synced_at: datetime | None
|
last_state_synced_at: datetime | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_workstreams(self) -> list[DispatchWorkplan]:
|
||||||
|
return self.active_workplans
|
||||||
|
|
||||||
|
|
||||||
class RepoScopeHealth(BaseModel):
|
class RepoScopeHealth(BaseModel):
|
||||||
repo_slug: str
|
repo_slug: str
|
||||||
@@ -104,4 +176,4 @@ class RepoScopeHealth(BaseModel):
|
|||||||
local_path: str | None = None
|
local_path: str | None = None
|
||||||
path_available: bool
|
path_available: bool
|
||||||
scope_needs_review: bool
|
scope_needs_review: bool
|
||||||
scope_issue_details: list[ScopeIssueDetail]
|
scope_issue_details: list[ScopeIssueDetail]
|
||||||
@@ -2,12 +2,17 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||||
|
|
||||||
|
|
||||||
class ProgressEventCreate(BaseModel):
|
class ProgressEventCreate(BaseModel):
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
decision_id: uuid.UUID | None = None
|
decision_id: uuid.UUID | None = None
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -17,11 +22,10 @@ class ProgressEventCreate(BaseModel):
|
|||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProgressEventRead(BaseModel):
|
class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
decision_id: uuid.UUID | None = None
|
decision_id: uuid.UUID | None = None
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -29,4 +33,4 @@ class ProgressEventRead(BaseModel):
|
|||||||
detail: dict[str, Any] | None = None
|
detail: dict[str, Any] | None = None
|
||||||
author: str | None = None
|
author: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
116
api/schemas/service.py
Normal file
116
api/schemas/service.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Schemas for the two-dimension service catalog (STATE-WP-0062)."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
# ── Extension read models ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ServiceThirdPartyRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
pricing_model: str
|
||||||
|
upstream_packages: list | None = None
|
||||||
|
upstream_contacts: list | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
support_url: str | None = None
|
||||||
|
license: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFirstPartyRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
repo_id: uuid.UUID | None = None
|
||||||
|
owning_domain: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCloudRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
gdpr_maturity: str
|
||||||
|
gdpr_notes: str | None = None
|
||||||
|
dpa_available: bool
|
||||||
|
tos_url: str | None = None
|
||||||
|
privacy_policy_url: str | None = None
|
||||||
|
data_processing_regions: list | None = None
|
||||||
|
data_retention_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSelfHostedRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
helix_instance: str | None = None
|
||||||
|
host_node: str | None = None
|
||||||
|
deployment_ref: str | None = None
|
||||||
|
runbook_ref: str | None = None
|
||||||
|
upstream_oss_project: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCatalogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: uuid.UUID
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
owner_or_provider: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
website_url: str | None = None
|
||||||
|
status: str
|
||||||
|
hosting_type: str
|
||||||
|
development_type: str
|
||||||
|
maturity_level: int | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
third_party: ServiceThirdPartyRead | None = None
|
||||||
|
first_party: ServiceFirstPartyRead | None = None
|
||||||
|
cloud: ServiceCloudRead | None = None
|
||||||
|
self_hosted: ServiceSelfHostedRead | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Write (upsert) models ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ServiceThirdPartyIn(BaseModel):
|
||||||
|
pricing_model: str = "unknown"
|
||||||
|
upstream_packages: list | None = None
|
||||||
|
upstream_contacts: list | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
support_url: str | None = None
|
||||||
|
license: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFirstPartyIn(BaseModel):
|
||||||
|
repo_id: uuid.UUID | None = None
|
||||||
|
repo_slug: str | None = None
|
||||||
|
owning_domain: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceCloudIn(BaseModel):
|
||||||
|
gdpr_maturity: str = "unknown"
|
||||||
|
gdpr_notes: str | None = None
|
||||||
|
dpa_available: bool = False
|
||||||
|
tos_url: str | None = None
|
||||||
|
privacy_policy_url: str | None = None
|
||||||
|
data_processing_regions: list | None = None
|
||||||
|
data_retention_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSelfHostedIn(BaseModel):
|
||||||
|
helix_instance: str | None = None
|
||||||
|
host_node: str | None = None
|
||||||
|
deployment_ref: str | None = None
|
||||||
|
runbook_ref: str | None = None
|
||||||
|
upstream_oss_project: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUpsert(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
owner_or_provider: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
website_url: str | None = None
|
||||||
|
status: str = "active"
|
||||||
|
hosting_type: str # self_hosted | cloud_hosted
|
||||||
|
development_type: str # first_party | third_party
|
||||||
|
maturity_level: int | None = None
|
||||||
|
third_party: ServiceThirdPartyIn | None = None
|
||||||
|
first_party: ServiceFirstPartyIn | None = None
|
||||||
|
cloud: ServiceCloudIn | None = None
|
||||||
|
self_hosted: ServiceSelfHostedIn | None = None
|
||||||
@@ -5,6 +5,7 @@ from typing import Self
|
|||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
from api.models.task import TaskPriority, TaskStatus
|
from api.models.task import TaskPriority, TaskStatus
|
||||||
|
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
||||||
from api.task_status import normalize_task_status
|
from api.task_status import normalize_task_status
|
||||||
|
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ class TaskStatusMixin(BaseModel):
|
|||||||
return normalize_task_status(value)
|
return normalize_task_status(value)
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(TaskStatusMixin):
|
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||||
workstream_id: uuid.UUID
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus = TaskStatus.todo
|
status: TaskStatus = TaskStatus.todo
|
||||||
@@ -96,10 +96,9 @@ class TaskStatusBulkSync(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class TaskRead(TaskStatusMixin):
|
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
workstream_id: uuid.UUID
|
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
@@ -114,8 +113,7 @@ class TaskRead(TaskStatusMixin):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class TaskCountRead(TaskStatusMixin):
|
class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||||
workstream_id: uuid.UUID
|
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from api.models.technical_debt import TDStatus
|
from api.models.technical_debt import TDStatus
|
||||||
|
|
||||||
@@ -35,7 +35,10 @@ class TDCreate(BaseModel):
|
|||||||
severity: str = "medium"
|
severity: str = "medium"
|
||||||
status: TDStatus = TDStatus.open
|
status: TDStatus = TDStatus.open
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TDUpdate(BaseModel):
|
class TDUpdate(BaseModel):
|
||||||
@@ -45,7 +48,10 @@ class TDUpdate(BaseModel):
|
|||||||
debt_type: str | None = None
|
debt_type: str | None = None
|
||||||
severity: str | None = None
|
severity: str | None = None
|
||||||
status: TDStatus | None = None
|
status: TDStatus | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TDRead(BaseModel):
|
class TDRead(BaseModel):
|
||||||
@@ -61,7 +67,11 @@ class TDRead(BaseModel):
|
|||||||
severity: str
|
severity: str
|
||||||
status: TDStatus
|
status: TDStatus
|
||||||
topic_id: uuid.UUID | None = None
|
topic_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.workplan_id
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
notes: list[TDNoteRead] = []
|
notes: list[TDNoteRead] = []
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||||
|
|
||||||
|
|
||||||
class TokenEventCreate(BaseModel):
|
class TokenEventCreate(BaseModel):
|
||||||
tokens_in: int
|
tokens_in: int
|
||||||
tokens_out: int
|
tokens_out: int
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel):
|
|||||||
raw_metadata: dict[str, Any] | None = None
|
raw_metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenEventRead(BaseModel):
|
class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
tokens_in: int
|
tokens_in: int
|
||||||
tokens_out: int
|
tokens_out: int
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel):
|
|||||||
tokens_in: int | None = None
|
tokens_in: int | None = None
|
||||||
tokens_out: int | None = None
|
tokens_out: int | None = None
|
||||||
task_id: uuid.UUID | None = None
|
task_id: uuid.UUID | None = None
|
||||||
workstream_id: uuid.UUID | None = None
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
repo_id: uuid.UUID | None = None
|
repo_id: uuid.UUID | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|||||||
107
api/schemas/workplan.py
Normal file
107
api/schemas/workplan.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
|
from api.schemas.workplan_dependency import WorkplanDepStub
|
||||||
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
|
WorkplanStatus = Literal[
|
||||||
|
"proposed",
|
||||||
|
"ready",
|
||||||
|
"active",
|
||||||
|
"blocked",
|
||||||
|
"backlog",
|
||||||
|
"finished",
|
||||||
|
"archived",
|
||||||
|
]
|
||||||
|
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
||||||
|
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
|
||||||
|
ConcurrencyMode = Literal["sequential", "parallel"]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanStatusMixin(BaseModel):
|
||||||
|
@field_validator("status", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalise_status(cls, value):
|
||||||
|
return normalize_workplan_status(value)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanCreate(WorkplanStatusMixin):
|
||||||
|
repo_id: uuid.UUID
|
||||||
|
topic_id: uuid.UUID | None = None
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
status: WorkplanStatus = "active"
|
||||||
|
owner: str | None = None
|
||||||
|
due_date: date | None = None
|
||||||
|
planning_priority: str | None = None
|
||||||
|
planning_order: int | None = None
|
||||||
|
execution_state: ExecutionState = "manual"
|
||||||
|
launch_mode: LaunchMode = "manual"
|
||||||
|
concurrency_mode: ConcurrencyMode = "sequential"
|
||||||
|
queue_rank: int | None = None
|
||||||
|
execution_group: str | None = None
|
||||||
|
scheduled_for: datetime | None = None
|
||||||
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanUpdate(WorkplanStatusMixin):
|
||||||
|
title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: WorkplanStatus | None = None
|
||||||
|
owner: str | None = None
|
||||||
|
due_date: date | None = None
|
||||||
|
planning_priority: str | None = None
|
||||||
|
planning_order: int | None = None
|
||||||
|
execution_state: ExecutionState | None = None
|
||||||
|
launch_mode: LaunchMode | None = None
|
||||||
|
concurrency_mode: ConcurrencyMode | None = None
|
||||||
|
queue_rank: int | None = None
|
||||||
|
execution_group: str | None = None
|
||||||
|
scheduled_for: datetime | None = None
|
||||||
|
topic_id: uuid.UUID | None = None
|
||||||
|
repo_id: uuid.UUID | None = None
|
||||||
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanRead(WorkplanStatusMixin):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: uuid.UUID
|
||||||
|
repo_id: uuid.UUID
|
||||||
|
topic_id: uuid.UUID | None = None
|
||||||
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
description: str | None = None
|
||||||
|
status: WorkplanStatus
|
||||||
|
owner: str | None = None
|
||||||
|
due_date: date | None = None
|
||||||
|
planning_priority: str | None = None
|
||||||
|
planning_order: int | None = None
|
||||||
|
execution_state: ExecutionState = "manual"
|
||||||
|
launch_mode: LaunchMode = "manual"
|
||||||
|
concurrency_mode: ConcurrencyMode = "sequential"
|
||||||
|
queue_rank: int | None = None
|
||||||
|
execution_group: str | None = None
|
||||||
|
scheduled_for: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanWithTaskCounts(WorkplanRead):
|
||||||
|
tasks_total: int = 0
|
||||||
|
tasks_wait: int = 0
|
||||||
|
tasks_todo: int = 0
|
||||||
|
tasks_progress: int = 0
|
||||||
|
tasks_done: int = 0
|
||||||
|
tasks_cancel: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanWithDeps(WorkplanWithTaskCounts):
|
||||||
|
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
|
||||||
|
depends_on: list[WorkplanDepStub] = []
|
||||||
|
blocks: list[WorkplanDepStub] = []
|
||||||
|
blocked_reasons: list[dict] = []
|
||||||
63
api/schemas/workplan_dependency.py
Normal file
63
api/schemas/workplan_dependency.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanDependencyCreate(BaseModel):
|
||||||
|
to_workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("to_workplan_id", "to_workstream_id"),
|
||||||
|
)
|
||||||
|
to_task_id: uuid.UUID | None = None
|
||||||
|
relationship_type: str = "blocks"
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanDependencyRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: uuid.UUID
|
||||||
|
from_workplan_id: uuid.UUID
|
||||||
|
to_workplan_id: uuid.UUID | None = None
|
||||||
|
to_task_id: uuid.UUID | None = None
|
||||||
|
relationship_type: str
|
||||||
|
description: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkplanDepStub(BaseModel):
|
||||||
|
"""Minimal projection of the other end of a dependency edge."""
|
||||||
|
dep_id: uuid.UUID
|
||||||
|
target_type: str = "workplan"
|
||||||
|
relationship_type: str = "blocks"
|
||||||
|
workplan_id: uuid.UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_id", "workstream_id"),
|
||||||
|
)
|
||||||
|
workplan_slug: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_slug", "workstream_slug"),
|
||||||
|
)
|
||||||
|
workplan_title: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("workplan_title", "workstream_title"),
|
||||||
|
)
|
||||||
|
task_id: uuid.UUID | None = None
|
||||||
|
task_title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_id(self) -> uuid.UUID | None:
|
||||||
|
return self.workplan_id
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_slug(self) -> str | None:
|
||||||
|
return self.workplan_slug
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def workstream_title(self) -> str | None:
|
||||||
|
return self.workplan_title
|
||||||
@@ -1,106 +1,41 @@
|
|||||||
import uuid
|
"""Legacy aliases — prefer ``api.schemas.workplan``."""
|
||||||
from datetime import date, datetime
|
from api.schemas.workplan import (
|
||||||
from typing import Literal
|
ConcurrencyMode,
|
||||||
|
ExecutionState,
|
||||||
|
LaunchMode,
|
||||||
|
WorkplanCreate,
|
||||||
|
WorkplanRead,
|
||||||
|
WorkplanStatus,
|
||||||
|
WorkplanStatusMixin,
|
||||||
|
WorkplanUpdate,
|
||||||
|
WorkplanWithDeps,
|
||||||
|
WorkplanWithTaskCounts,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
WorkstreamStatus = WorkplanStatus
|
||||||
|
WorkstreamStatusMixin = WorkplanStatusMixin
|
||||||
|
WorkstreamCreate = WorkplanCreate
|
||||||
|
WorkstreamUpdate = WorkplanUpdate
|
||||||
|
WorkstreamRead = WorkplanRead
|
||||||
|
WorkstreamWithTaskCounts = WorkplanWithTaskCounts
|
||||||
|
WorkstreamWithDeps = WorkplanWithDeps
|
||||||
|
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
__all__ = [
|
||||||
from api.workplan_status import normalize_workstream_status
|
"WorkstreamStatus",
|
||||||
|
"WorkstreamStatusMixin",
|
||||||
WorkstreamStatus = Literal[
|
"WorkstreamCreate",
|
||||||
"proposed",
|
"WorkstreamUpdate",
|
||||||
"ready",
|
"WorkstreamRead",
|
||||||
"active",
|
"WorkstreamWithTaskCounts",
|
||||||
"blocked",
|
"WorkstreamWithDeps",
|
||||||
"backlog",
|
"WorkplanStatus",
|
||||||
"finished",
|
"WorkplanStatusMixin",
|
||||||
"archived",
|
"WorkplanCreate",
|
||||||
]
|
"WorkplanUpdate",
|
||||||
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
|
"WorkplanRead",
|
||||||
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
|
"WorkplanWithTaskCounts",
|
||||||
ConcurrencyMode = Literal["sequential", "parallel"]
|
"WorkplanWithDeps",
|
||||||
|
"ExecutionState",
|
||||||
|
"LaunchMode",
|
||||||
class WorkstreamStatusMixin(BaseModel):
|
"ConcurrencyMode",
|
||||||
@field_validator("status", mode="before", check_fields=False)
|
]
|
||||||
@classmethod
|
|
||||||
def _normalise_status(cls, value):
|
|
||||||
return normalize_workstream_status(value)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamCreate(WorkstreamStatusMixin):
|
|
||||||
topic_id: uuid.UUID
|
|
||||||
slug: str
|
|
||||||
title: str
|
|
||||||
description: str | None = None
|
|
||||||
status: WorkstreamStatus = "active"
|
|
||||||
owner: str | None = None
|
|
||||||
due_date: date | None = None
|
|
||||||
planning_priority: str | None = None
|
|
||||||
planning_order: int | None = None
|
|
||||||
execution_state: ExecutionState = "manual"
|
|
||||||
launch_mode: LaunchMode = "manual"
|
|
||||||
concurrency_mode: ConcurrencyMode = "sequential"
|
|
||||||
queue_rank: int | None = None
|
|
||||||
execution_group: str | None = None
|
|
||||||
scheduled_for: datetime | None = None
|
|
||||||
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
|
|
||||||
repo_goal_id: uuid.UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamUpdate(WorkstreamStatusMixin):
|
|
||||||
title: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
status: WorkstreamStatus | None = None
|
|
||||||
owner: str | None = None
|
|
||||||
due_date: date | None = None
|
|
||||||
planning_priority: str | None = None
|
|
||||||
planning_order: int | None = None
|
|
||||||
execution_state: ExecutionState | None = None
|
|
||||||
launch_mode: LaunchMode | None = None
|
|
||||||
concurrency_mode: ConcurrencyMode | None = None
|
|
||||||
queue_rank: int | None = None
|
|
||||||
execution_group: str | None = None
|
|
||||||
scheduled_for: datetime | None = None
|
|
||||||
repo_id: uuid.UUID | None = None
|
|
||||||
repo_goal_id: uuid.UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamRead(WorkstreamStatusMixin):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: uuid.UUID
|
|
||||||
topic_id: uuid.UUID
|
|
||||||
repo_id: uuid.UUID | None = None
|
|
||||||
repo_goal_id: uuid.UUID | None = None
|
|
||||||
slug: str
|
|
||||||
title: str
|
|
||||||
description: str | None = None
|
|
||||||
status: WorkstreamStatus
|
|
||||||
owner: str | None = None
|
|
||||||
due_date: date | None = None
|
|
||||||
planning_priority: str | None = None
|
|
||||||
planning_order: int | None = None
|
|
||||||
execution_state: ExecutionState = "manual"
|
|
||||||
launch_mode: LaunchMode = "manual"
|
|
||||||
concurrency_mode: ConcurrencyMode = "sequential"
|
|
||||||
queue_rank: int | None = None
|
|
||||||
execution_group: str | None = None
|
|
||||||
scheduled_for: datetime | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamWithTaskCounts(WorkstreamRead):
|
|
||||||
tasks_total: int = 0
|
|
||||||
tasks_wait: int = 0
|
|
||||||
tasks_todo: int = 0
|
|
||||||
tasks_progress: int = 0
|
|
||||||
tasks_done: int = 0
|
|
||||||
tasks_cancel: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
|
||||||
"""WorkstreamWithTaskCounts enriched with dependency graph edges."""
|
|
||||||
depends_on: list[WorkstreamDepStub] = []
|
|
||||||
blocks: list[WorkstreamDepStub] = []
|
|
||||||
blocked_reasons: list[dict] = []
|
|
||||||
@@ -1,36 +1,19 @@
|
|||||||
import uuid
|
"""Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
|
||||||
from datetime import datetime
|
from api.schemas.workplan_dependency import (
|
||||||
|
WorkplanDepStub,
|
||||||
|
WorkplanDependencyCreate,
|
||||||
|
WorkplanDependencyRead,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
WorkstreamDependencyCreate = WorkplanDependencyCreate
|
||||||
|
WorkstreamDependencyRead = WorkplanDependencyRead
|
||||||
|
WorkstreamDepStub = WorkplanDepStub
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
class WorkstreamDependencyCreate(BaseModel):
|
"WorkstreamDependencyCreate",
|
||||||
to_workstream_id: uuid.UUID | None = None
|
"WorkstreamDependencyRead",
|
||||||
to_task_id: uuid.UUID | None = None
|
"WorkstreamDepStub",
|
||||||
relationship_type: str = "blocks"
|
"WorkplanDependencyCreate",
|
||||||
description: str | None = None
|
"WorkplanDependencyRead",
|
||||||
|
"WorkplanDepStub",
|
||||||
|
]
|
||||||
class WorkstreamDependencyRead(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: uuid.UUID
|
|
||||||
from_workstream_id: uuid.UUID
|
|
||||||
to_workstream_id: uuid.UUID | None = None
|
|
||||||
to_task_id: uuid.UUID | None = None
|
|
||||||
relationship_type: str
|
|
||||||
description: str | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamDepStub(BaseModel):
|
|
||||||
"""Minimal projection of the other end of a dependency edge."""
|
|
||||||
dep_id: uuid.UUID
|
|
||||||
target_type: str = "workstream"
|
|
||||||
relationship_type: str = "blocks"
|
|
||||||
workstream_id: uuid.UUID | None = None
|
|
||||||
workstream_slug: str | None = None
|
|
||||||
workstream_title: str | None = None
|
|
||||||
task_id: uuid.UUID | None = None
|
|
||||||
task_title: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
215
api/services/consistency_sweep.py
Normal file
215
api/services/consistency_sweep.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.config import settings
|
||||||
|
from api.models.progress_event import ProgressEvent
|
||||||
|
from api.schemas.consistency_sweep import (
|
||||||
|
ConsistencySweepIssueSummary,
|
||||||
|
ConsistencySweepRemoteAllRun,
|
||||||
|
ConsistencySweepRepoResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOCK_SKIP_MARKER = "another fix-consistency-remote --all run is already active"
|
||||||
|
_CLEAN_RE = re.compile(r"^\s*CLEAN \(skipped\):\s*(.+)$", re.MULTILINE)
|
||||||
|
_MISSING_RE = re.compile(r"^\s*NOT ON THIS HOST \(skipped\):\s*(.+)$", re.MULTILINE)
|
||||||
|
_BUDGET_RE = re.compile(
|
||||||
|
r"^\s*BUDGET EXHAUSTED after \d+s \(skipped\):\s*(.+)$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _script_path() -> Path:
|
||||||
|
return Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_slug_list(value: str) -> list[str]:
|
||||||
|
return [part.strip() for part in value.split(",") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_stderr(stderr: str) -> dict[str, list[str]]:
|
||||||
|
return {
|
||||||
|
"skipped_clean": _split_slug_list(_CLEAN_RE.search(stderr).group(1))
|
||||||
|
if _CLEAN_RE.search(stderr)
|
||||||
|
else [],
|
||||||
|
"skipped_missing": _split_slug_list(_MISSING_RE.search(stderr).group(1))
|
||||||
|
if _MISSING_RE.search(stderr)
|
||||||
|
else [],
|
||||||
|
"skipped_budget": _split_slug_list(_BUDGET_RE.search(stderr).group(1))
|
||||||
|
if _BUDGET_RE.search(stderr)
|
||||||
|
else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_payload(text: str) -> Any:
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped:
|
||||||
|
return []
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
for index, char in enumerate(stripped):
|
||||||
|
if char not in "{[":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload, _end = decoder.raw_decode(stripped, index)
|
||||||
|
return payload
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
raise json.JSONDecodeError("No JSON payload found", stripped, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_stdout(stdout: str) -> list[ConsistencySweepRepoResult]:
|
||||||
|
text = stdout.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
payload = _extract_json_payload(text)
|
||||||
|
items = payload if isinstance(payload, list) else [payload]
|
||||||
|
results: list[ConsistencySweepRepoResult] = []
|
||||||
|
for item in items:
|
||||||
|
summary = item.get("summary") or {}
|
||||||
|
results.append(
|
||||||
|
ConsistencySweepRepoResult(
|
||||||
|
repo_slug=str(item.get("repo_slug") or ""),
|
||||||
|
repo_path=str(item.get("repo_path") or ""),
|
||||||
|
result=str(item.get("result") or "pass"),
|
||||||
|
summary=ConsistencySweepIssueSummary(
|
||||||
|
fail=int(summary.get("fail", 0)),
|
||||||
|
automation_error=int(summary.get("automation_error", 0)),
|
||||||
|
warn=int(summary.get("warn", 0)),
|
||||||
|
info=int(summary.get("info", 0)),
|
||||||
|
),
|
||||||
|
fixes_applied=list(item.get("fixes_applied") or []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def run_remote_all_sweep(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
max_seconds: int,
|
||||||
|
source: str = "api",
|
||||||
|
) -> ConsistencySweepRemoteAllRun:
|
||||||
|
started_at = datetime.now(tz=UTC)
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(_script_path()),
|
||||||
|
"--remote",
|
||||||
|
"--all",
|
||||||
|
"--json",
|
||||||
|
"--api-base",
|
||||||
|
settings.api_base,
|
||||||
|
"--max-seconds",
|
||||||
|
str(max_seconds),
|
||||||
|
]
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
completed_at = datetime.now(tz=UTC)
|
||||||
|
lock_skipped = _LOCK_SKIP_MARKER in result.stderr
|
||||||
|
stderr_meta = _parse_stderr(result.stderr)
|
||||||
|
repos_processed = [] if lock_skipped else _parse_stdout(result.stdout)
|
||||||
|
|
||||||
|
automation_error = result.returncode != 0 and not lock_skipped
|
||||||
|
progress_event_id = await _log_sweep_progress(
|
||||||
|
session,
|
||||||
|
started_at=started_at,
|
||||||
|
completed_at=completed_at,
|
||||||
|
max_seconds=max_seconds,
|
||||||
|
source=source,
|
||||||
|
exit_code=result.returncode,
|
||||||
|
automation_error=automation_error,
|
||||||
|
lock_skipped=lock_skipped,
|
||||||
|
repos_processed=repos_processed,
|
||||||
|
**stderr_meta,
|
||||||
|
)
|
||||||
|
return ConsistencySweepRemoteAllRun(
|
||||||
|
started_at=started_at,
|
||||||
|
completed_at=completed_at,
|
||||||
|
max_seconds=max_seconds,
|
||||||
|
source=source,
|
||||||
|
exit_code=result.returncode,
|
||||||
|
automation_error=automation_error,
|
||||||
|
lock_skipped=lock_skipped,
|
||||||
|
repos_processed=repos_processed,
|
||||||
|
skipped_clean=stderr_meta["skipped_clean"],
|
||||||
|
skipped_missing=stderr_meta["skipped_missing"],
|
||||||
|
skipped_budget=stderr_meta["skipped_budget"],
|
||||||
|
progress_event_id=progress_event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_sweep_progress(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
started_at: datetime,
|
||||||
|
completed_at: datetime,
|
||||||
|
max_seconds: int,
|
||||||
|
source: str,
|
||||||
|
exit_code: int,
|
||||||
|
automation_error: bool,
|
||||||
|
lock_skipped: bool,
|
||||||
|
repos_processed: list[ConsistencySweepRepoResult],
|
||||||
|
skipped_clean: list[str],
|
||||||
|
skipped_missing: list[str],
|
||||||
|
skipped_budget: list[str],
|
||||||
|
) -> uuid.UUID:
|
||||||
|
processed_count = len(repos_processed)
|
||||||
|
error_count = sum(1 for repo in repos_processed if repo.result == "error")
|
||||||
|
assessment_fail_count = sum(1 for repo in repos_processed if repo.result == "fail")
|
||||||
|
warn_count = sum(1 for repo in repos_processed if repo.result == "warn")
|
||||||
|
if lock_skipped:
|
||||||
|
summary = "State Hub consistency sweep skipped: prior remote-all run still active"
|
||||||
|
elif automation_error:
|
||||||
|
summary = (
|
||||||
|
"State Hub consistency sweep automation error: "
|
||||||
|
f"exit_code={exit_code}, {processed_count} repos partially processed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary = (
|
||||||
|
"State Hub consistency sweep completed: "
|
||||||
|
f"{processed_count} processed, {len(skipped_clean)} clean, "
|
||||||
|
f"{len(skipped_missing)} missing, {len(skipped_budget)} budget-skipped, "
|
||||||
|
f"{assessment_fail_count} assessment-fail, {error_count} automation-error, "
|
||||||
|
f"{warn_count} warned"
|
||||||
|
)
|
||||||
|
event = ProgressEvent(
|
||||||
|
event_type="consistency_sweep_remote_all",
|
||||||
|
summary=summary,
|
||||||
|
detail={
|
||||||
|
"started_at": _iso(started_at),
|
||||||
|
"completed_at": _iso(completed_at),
|
||||||
|
"max_seconds": max_seconds,
|
||||||
|
"source": source,
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"automation_error": automation_error,
|
||||||
|
"assessment_failures": assessment_fail_count,
|
||||||
|
"automation_errors": error_count,
|
||||||
|
"lock_skipped": lock_skipped,
|
||||||
|
"repos_processed": [item.model_dump(mode="json") for item in repos_processed],
|
||||||
|
"skipped_clean": skipped_clean,
|
||||||
|
"skipped_missing": skipped_missing,
|
||||||
|
"skipped_budget": skipped_budget,
|
||||||
|
},
|
||||||
|
author="state-hub",
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(event)
|
||||||
|
return event.id
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime) -> str:
|
||||||
|
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
EXECUTION_STATES = {
|
EXECUTION_STATES = {
|
||||||
@@ -57,7 +57,7 @@ PRIORITY_RANK = {
|
|||||||
"low": 3,
|
"low": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
CLOSED_WORKSTREAM_STATUSES = {"finished", "archived"}
|
CLOSED_WORKPLAN_STATUSES = {"finished", "archived"}
|
||||||
|
|
||||||
|
|
||||||
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
|
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
|
||||||
@@ -71,19 +71,24 @@ def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False)
|
|||||||
return "queued"
|
return "queued"
|
||||||
|
|
||||||
|
|
||||||
def workstream_blockers(
|
def workplan_blockers(
|
||||||
workstream_id: Any,
|
workplan_id: Any,
|
||||||
dependency_targets: dict[Any, list[Any]],
|
dependency_targets: dict[Any, list[Any]],
|
||||||
workstream_status: dict[Any, str],
|
workplan_status: dict[Any, str],
|
||||||
|
workstream_id: Any = None,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
|
scope_id = workplan_id if workplan_id is not None else workstream_id
|
||||||
blockers = []
|
blockers = []
|
||||||
for target_id in dependency_targets.get(workstream_id, []):
|
for target_id in dependency_targets.get(scope_id, []):
|
||||||
target_status = normalize_workstream_status(workstream_status.get(target_id))
|
target_status = normalize_workplan_status(workplan_status.get(target_id))
|
||||||
if target_status not in CLOSED_WORKSTREAM_STATUSES:
|
if target_status not in CLOSED_WORKPLAN_STATUSES:
|
||||||
blockers.append(target_id)
|
blockers.append(target_id)
|
||||||
return blockers
|
return blockers
|
||||||
|
|
||||||
|
|
||||||
|
workstream_blockers = workplan_blockers
|
||||||
|
|
||||||
|
|
||||||
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
|
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
|
||||||
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
|
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
|
||||||
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
|
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
|
||||||
from api.workplan_status import normalize_workstream_status
|
from api.workplan_status import normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
TASK_STARTED_STATUS = "progress"
|
TASK_STARTED_STATUS = "progress"
|
||||||
TASK_NOT_STARTED_STATUS = "todo"
|
TASK_NOT_STARTED_STATUS = "todo"
|
||||||
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
||||||
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
||||||
|
|
||||||
|
# Legacy alias
|
||||||
|
normalize_workstream_status = normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LifecycleTransitionResult:
|
class LifecycleTransitionResult:
|
||||||
@@ -26,13 +28,15 @@ def should_activate_parent_for_task_start(
|
|||||||
*,
|
*,
|
||||||
previous_task_status: Any,
|
previous_task_status: Any,
|
||||||
new_task_status: Any,
|
new_task_status: Any,
|
||||||
parent_workstream_status: Any,
|
parent_workplan_status: Any = None,
|
||||||
|
parent_workstream_status: Any = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether a task start should move its parent to active."""
|
"""Return whether a task start should move its parent to active."""
|
||||||
|
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
|
||||||
return (
|
return (
|
||||||
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
|
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
|
||||||
and status_value(new_task_status) == TASK_STARTED_STATUS
|
and status_value(new_task_status) == TASK_STARTED_STATUS
|
||||||
and normalize_workstream_status(parent_workstream_status)
|
and normalize_workplan_status(parent_status)
|
||||||
in PARENT_ACTIVATION_STATUSES
|
in PARENT_ACTIVATION_STATUSES
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,12 +48,14 @@ def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
|||||||
|
|
||||||
def should_activate_parent_for_active_tasks(
|
def should_activate_parent_for_active_tasks(
|
||||||
*,
|
*,
|
||||||
parent_workstream_status: Any,
|
parent_workplan_status: Any = None,
|
||||||
|
parent_workstream_status: Any = None,
|
||||||
task_statuses: list[Any] | tuple[Any, ...],
|
task_statuses: list[Any] | tuple[Any, ...],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether existing task state implies an active parent workstream."""
|
"""Return whether existing task state implies an active parent workplan."""
|
||||||
|
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
|
||||||
return (
|
return (
|
||||||
normalize_workstream_status(parent_workstream_status)
|
normalize_workplan_status(parent_status)
|
||||||
in PARENT_ACTIVATION_STATUSES
|
in PARENT_ACTIVATION_STATUSES
|
||||||
and has_active_task_status(task_statuses)
|
and has_active_task_status(task_statuses)
|
||||||
)
|
)
|
||||||
@@ -59,46 +65,54 @@ def activate_parent_for_task_start(
|
|||||||
*,
|
*,
|
||||||
previous_task_status: Any,
|
previous_task_status: Any,
|
||||||
new_task_status: Any,
|
new_task_status: Any,
|
||||||
parent_workstream: Any,
|
parent_workplan: Any = None,
|
||||||
|
parent_workstream: Any = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Activate a planning-state parent workstream when real task work starts."""
|
"""Activate a planning-state parent workplan when real task work starts."""
|
||||||
if parent_workstream is None:
|
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||||
|
if parent is None:
|
||||||
return False
|
return False
|
||||||
if not should_activate_parent_for_task_start(
|
if not should_activate_parent_for_task_start(
|
||||||
previous_task_status=previous_task_status,
|
previous_task_status=previous_task_status,
|
||||||
new_task_status=new_task_status,
|
new_task_status=new_task_status,
|
||||||
parent_workstream_status=getattr(parent_workstream, "status", None),
|
parent_workplan_status=getattr(parent, "status", None),
|
||||||
|
parent_workstream_status=getattr(parent, "status", None),
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
parent_workstream.status = "active"
|
parent.status = "active"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def transition_workstream_status(
|
def transition_workplan_status(
|
||||||
workstream: Any,
|
workplan: Any,
|
||||||
target_status: Any,
|
target_status: Any,
|
||||||
) -> LifecycleTransitionResult:
|
) -> LifecycleTransitionResult:
|
||||||
"""Apply a canonical workstream status transition."""
|
"""Apply a canonical workplan status transition."""
|
||||||
previous_status = normalize_workstream_status(getattr(workstream, "status", None))
|
previous_status = normalize_workplan_status(getattr(workplan, "status", None))
|
||||||
normalised_target = normalize_workstream_status(target_status)
|
normalised_target = normalize_workplan_status(target_status)
|
||||||
workstream.status = normalised_target
|
workplan.status = normalised_target
|
||||||
return LifecycleTransitionResult(
|
return LifecycleTransitionResult(
|
||||||
entity_type="workstream",
|
entity_type="workplan",
|
||||||
previous_status=previous_status,
|
previous_status=previous_status,
|
||||||
target_status=normalised_target,
|
target_status=normalised_target,
|
||||||
changed=previous_status != normalised_target,
|
changed=previous_status != normalised_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
transition_workstream_status = transition_workplan_status
|
||||||
|
|
||||||
|
|
||||||
def transition_task_status(
|
def transition_task_status(
|
||||||
task: Any,
|
task: Any,
|
||||||
target_status: Any,
|
target_status: Any,
|
||||||
*,
|
*,
|
||||||
|
parent_workplan: Any = None,
|
||||||
parent_workstream: Any = None,
|
parent_workstream: Any = None,
|
||||||
previous_task_status: Any = None,
|
previous_task_status: Any = None,
|
||||||
status_coercer: Any = None,
|
status_coercer: Any = None,
|
||||||
) -> LifecycleTransitionResult:
|
) -> LifecycleTransitionResult:
|
||||||
"""Apply a task status transition and activate the parent when work starts."""
|
"""Apply a task status transition and activate the parent when work starts."""
|
||||||
|
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||||
previous_status = status_value(
|
previous_status = status_value(
|
||||||
getattr(task, "status", None)
|
getattr(task, "status", None)
|
||||||
if previous_task_status is None
|
if previous_task_status is None
|
||||||
@@ -109,7 +123,8 @@ def transition_task_status(
|
|||||||
parent_activated = activate_parent_for_task_start(
|
parent_activated = activate_parent_for_task_start(
|
||||||
previous_task_status=previous_status,
|
previous_task_status=previous_status,
|
||||||
new_task_status=normalised_target,
|
new_task_status=normalised_target,
|
||||||
parent_workstream=parent_workstream,
|
parent_workplan=parent,
|
||||||
|
parent_workstream=parent,
|
||||||
)
|
)
|
||||||
return LifecycleTransitionResult(
|
return LifecycleTransitionResult(
|
||||||
entity_type="task",
|
entity_type="task",
|
||||||
@@ -117,4 +132,4 @@ def transition_task_status(
|
|||||||
target_status=normalised_target,
|
target_status=normalised_target,
|
||||||
changed=previous_status != normalised_target,
|
changed=previous_status != normalised_target,
|
||||||
parent_activated=parent_activated,
|
parent_activated=parent_activated,
|
||||||
)
|
)
|
||||||
@@ -20,7 +20,7 @@ from api.models.managed_repo import ManagedRepo
|
|||||||
from api.models.progress_event import ProgressEvent
|
from api.models.progress_event import ProgressEvent
|
||||||
from api.models.task import Task, TaskStatus
|
from api.models.task import Task, TaskStatus
|
||||||
from api.models.topic import Topic
|
from api.models.topic import Topic
|
||||||
from api.models.workstream import Workstream
|
from api.models.workplan import Workplan
|
||||||
from api.schemas.recently_on_scope import (
|
from api.schemas.recently_on_scope import (
|
||||||
RecentlyOnScopeFailedDomain,
|
RecentlyOnScopeFailedDomain,
|
||||||
RecentlyOnScopeHourlyRun,
|
RecentlyOnScopeHourlyRun,
|
||||||
@@ -344,11 +344,11 @@ async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topi
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workstream]:
|
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workplan]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Workstream)
|
select(Workplan)
|
||||||
.where(_in(Workstream.topic_id, topic_ids))
|
.where(_in(Workplan.topic_id, topic_ids))
|
||||||
.order_by(Workstream.updated_at.desc(), Workstream.created_at.desc())
|
.order_by(Workplan.updated_at.desc(), Workplan.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -
|
|||||||
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
|
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Task)
|
select(Task)
|
||||||
.where(_in(Task.workstream_id, workstream_ids))
|
.where(_in(Task.workplan_id, workstream_ids))
|
||||||
.order_by(Task.updated_at.desc(), Task.created_at.desc())
|
.order_by(Task.updated_at.desc(), Task.created_at.desc())
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -370,7 +370,7 @@ async def _list_recent_decisions(
|
|||||||
) -> list[Decision]:
|
) -> list[Decision]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Decision)
|
select(Decision)
|
||||||
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workstream_id, workstream_ids)))
|
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workplan_id, workstream_ids)))
|
||||||
.where(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
_between(Decision.created_at, window),
|
_between(Decision.created_at, window),
|
||||||
@@ -397,7 +397,7 @@ async def _list_recent_progress(
|
|||||||
.where(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
_in(ProgressEvent.topic_id, topic_ids),
|
_in(ProgressEvent.topic_id, topic_ids),
|
||||||
_in(ProgressEvent.workstream_id, workstream_ids),
|
_in(ProgressEvent.workplan_id, workstream_ids),
|
||||||
_in(ProgressEvent.task_id, task_ids),
|
_in(ProgressEvent.task_id, task_ids),
|
||||||
_in(ProgressEvent.decision_id, decision_ids),
|
_in(ProgressEvent.decision_id, decision_ids),
|
||||||
)
|
)
|
||||||
@@ -550,7 +550,8 @@ def _progress_data(event: ProgressEvent) -> dict[str, Any]:
|
|||||||
"event_type": event.event_type,
|
"event_type": event.event_type,
|
||||||
"summary": event.summary,
|
"summary": event.summary,
|
||||||
"author": event.author,
|
"author": event.author,
|
||||||
"workstream_id": str(event.workstream_id) if event.workstream_id else None,
|
"workplan_id": str(event.workplan_id) if event.workplan_id else None,
|
||||||
|
"workstream_id": str(event.workplan_id) if event.workplan_id else None,
|
||||||
"task_id": str(event.task_id) if event.task_id else None,
|
"task_id": str(event.task_id) if event.task_id else None,
|
||||||
"decision_id": str(event.decision_id) if event.decision_id else None,
|
"decision_id": str(event.decision_id) if event.decision_id else None,
|
||||||
}
|
}
|
||||||
@@ -569,7 +570,7 @@ def _decision_data(decision: Decision) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
def _workstream_data(workstream: Workplan) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": str(workstream.id),
|
"id": str(workstream.id),
|
||||||
"slug": workstream.slug,
|
"slug": workstream.slug,
|
||||||
@@ -584,7 +585,7 @@ def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
|||||||
def _task_data(task: Task) -> dict[str, Any]:
|
def _task_data(task: Task) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": str(task.id),
|
"id": str(task.id),
|
||||||
"workstream_id": str(task.workstream_id),
|
"workstream_id": str(task.workplan_id),
|
||||||
"title": task.title,
|
"title": task.title,
|
||||||
"status": _enum_value(task.status),
|
"status": _enum_value(task.status),
|
||||||
"priority": _enum_value(task.priority),
|
"priority": _enum_value(task.priority),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from api.services.lifecycle import status_value
|
from api.services.lifecycle import status_value
|
||||||
from api.task_status import CANONICAL_TASK_STATUSES
|
from api.task_status import CANONICAL_TASK_STATUSES
|
||||||
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||||
|
|
||||||
|
|
||||||
class ReconciliationClass(str, Enum):
|
class ReconciliationClass(str, Enum):
|
||||||
@@ -22,11 +22,11 @@ class StateChangeClassification:
|
|||||||
follow_up: str
|
follow_up: str
|
||||||
|
|
||||||
|
|
||||||
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
|
WRITE_THROUGH_WORKPLAN_STATUSES = {"proposed", "ready", "active", "backlog"}
|
||||||
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
def classify_workstream_status_change(
|
def classify_workplan_status_change(
|
||||||
*,
|
*,
|
||||||
current_status: Any,
|
current_status: Any,
|
||||||
target_status: Any,
|
target_status: Any,
|
||||||
@@ -35,8 +35,8 @@ def classify_workstream_status_change(
|
|||||||
tasks_terminal: bool | None = None,
|
tasks_terminal: bool | None = None,
|
||||||
) -> StateChangeClassification:
|
) -> StateChangeClassification:
|
||||||
"""Classify a UI-originated workstream status transition."""
|
"""Classify a UI-originated workstream status transition."""
|
||||||
current = normalize_workstream_status(current_status)
|
current = normalize_workplan_status(current_status)
|
||||||
target = normalize_workstream_status(target_status)
|
target = normalize_workplan_status(target_status)
|
||||||
|
|
||||||
if not file_backed:
|
if not file_backed:
|
||||||
return StateChangeClassification(
|
return StateChangeClassification(
|
||||||
@@ -56,7 +56,7 @@ def classify_workstream_status_change(
|
|||||||
"status is unchanged",
|
"status is unchanged",
|
||||||
"no file update required",
|
"no file update required",
|
||||||
)
|
)
|
||||||
if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES:
|
if target in WRITE_THROUGH_WORKPLAN_STATUSES and current not in CLOSED_WORKPLAN_STATUSES:
|
||||||
return StateChangeClassification(
|
return StateChangeClassification(
|
||||||
ReconciliationClass.WRITE_THROUGH,
|
ReconciliationClass.WRITE_THROUGH,
|
||||||
"open lifecycle transition can be represented in workplan frontmatter",
|
"open lifecycle transition can be represented in workplan frontmatter",
|
||||||
@@ -93,6 +93,9 @@ def classify_workstream_status_change(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
classify_workstream_status_change = classify_workplan_status_change
|
||||||
|
|
||||||
|
|
||||||
def classify_task_status_change(
|
def classify_task_status_change(
|
||||||
*,
|
*,
|
||||||
current_status: Any,
|
current_status: Any,
|
||||||
|
|||||||
288
api/services/summary_cache.py
Normal file
288
api/services/summary_cache.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Revision-gated cache for ``GET /state/summary`` with stale-while-revalidate."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import noload
|
||||||
|
|
||||||
|
from api.models.capability_request import CapabilityRequest
|
||||||
|
from api.models.contribution import Contribution
|
||||||
|
from api.models.decision import Decision
|
||||||
|
from api.models.domain import Domain
|
||||||
|
from api.models.extension_point import ExtensionPoint
|
||||||
|
from api.models.managed_repo import ManagedRepo
|
||||||
|
from api.models.progress_event import ProgressEvent
|
||||||
|
from api.models.sbom_snapshot import SBOMSnapshot
|
||||||
|
from api.models.task import Task
|
||||||
|
from api.models.technical_debt import TechnicalDebt
|
||||||
|
from api.models.topic import Topic
|
||||||
|
from api.models.workplan import Workplan
|
||||||
|
from api.models.workplan_dependency import WorkplanDependency
|
||||||
|
from api.schemas.progress_event import ProgressEventRead
|
||||||
|
from api.schemas.state import StateSummary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||||
|
_MAX_STALE_AGE_SECONDS = 300.0
|
||||||
|
InvalidateScope = Literal["all", "core", "progress"]
|
||||||
|
CacheStatus = Literal["hit-revision", "stale", "miss", "progress-section"]
|
||||||
|
BuildSummaryFn = Callable[[AsyncSession], Awaitable[StateSummary]]
|
||||||
|
|
||||||
|
# Tables feeding the stable (non-progress) summary core.
|
||||||
|
_CORE_TABLES: tuple[tuple[str, type], ...] = (
|
||||||
|
("topics", Topic),
|
||||||
|
("workplans", Workplan),
|
||||||
|
("tasks", Task),
|
||||||
|
("decisions", Decision),
|
||||||
|
("workplan_dependencies", WorkplanDependency),
|
||||||
|
("managed_repos", ManagedRepo),
|
||||||
|
("contributions", Contribution),
|
||||||
|
("capability_requests", CapabilityRequest),
|
||||||
|
("domains", Domain),
|
||||||
|
("extension_points", ExtensionPoint),
|
||||||
|
("technical_debt", TechnicalDebt),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummaryRevision:
|
||||||
|
"""Cheap fingerprints of hub data that affect ``/state/summary``."""
|
||||||
|
|
||||||
|
core: datetime
|
||||||
|
progress: datetime | None
|
||||||
|
sbom: datetime | None
|
||||||
|
|
||||||
|
def core_fingerprint(self) -> str:
|
||||||
|
return _fingerprint(self.core, self.sbom)
|
||||||
|
|
||||||
|
def progress_fingerprint(self) -> str:
|
||||||
|
return self.progress.isoformat() if self.progress else ""
|
||||||
|
|
||||||
|
def combined_fingerprint(self) -> str:
|
||||||
|
return f"{self.core_fingerprint()}|{self.progress_fingerprint()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(*parts: datetime | None) -> str:
|
||||||
|
normalized = [
|
||||||
|
(part or _EPOCH).astimezone(timezone.utc).isoformat()
|
||||||
|
for part in parts
|
||||||
|
]
|
||||||
|
return "|".join(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
|
||||||
|
"""Return per-section revision watermarks (indexed MAX scans)."""
|
||||||
|
core_parts: list[datetime] = []
|
||||||
|
for _name, model in _CORE_TABLES:
|
||||||
|
value = (
|
||||||
|
await session.execute(select(func.max(model.updated_at)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if value is not None:
|
||||||
|
core_parts.append(value)
|
||||||
|
|
||||||
|
sbom_at = (
|
||||||
|
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
progress_at = (
|
||||||
|
await session.execute(select(func.max(ProgressEvent.created_at)))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
core = max(core_parts, default=_EPOCH)
|
||||||
|
if sbom_at is not None and sbom_at > core:
|
||||||
|
core = sbom_at
|
||||||
|
|
||||||
|
return SummaryRevision(core=core, progress=progress_at, sbom=sbom_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_recent_progress(session: AsyncSession, *, limit: int = 20) -> list[ProgressEventRead]:
|
||||||
|
rows = await session.execute(
|
||||||
|
select(ProgressEvent)
|
||||||
|
.options(noload("*"))
|
||||||
|
.order_by(ProgressEvent.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [ProgressEventRead.model_validate(event) for event in rows.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_summary(core: StateSummary, recent_progress: list[ProgressEventRead]) -> StateSummary:
|
||||||
|
return core.model_copy(update={"recent_progress": recent_progress})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CacheEntry:
|
||||||
|
summary: StateSummary
|
||||||
|
core_revision: str
|
||||||
|
progress_revision: str
|
||||||
|
built_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entry: _CacheEntry | None = None
|
||||||
|
self._refresh_task: asyncio.Task | None = None
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self._build_fn: BuildSummaryFn | None = None
|
||||||
|
|
||||||
|
def configure(self, build_fn: BuildSummaryFn) -> None:
|
||||||
|
self._build_fn = build_fn
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._entry = None
|
||||||
|
self.last_error = None
|
||||||
|
if self._refresh_task is not None and not self._refresh_task.done():
|
||||||
|
self._refresh_task.cancel()
|
||||||
|
self._refresh_task = None
|
||||||
|
|
||||||
|
def invalidate(self, scope: InvalidateScope = "all") -> None:
|
||||||
|
if scope == "all" or self._entry is None:
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
if scope == "core":
|
||||||
|
self.reset()
|
||||||
|
elif scope == "progress":
|
||||||
|
self._entry.progress_revision = "__invalid__"
|
||||||
|
|
||||||
|
def store(self, summary: StateSummary, revision: SummaryRevision) -> None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
self._entry = _CacheEntry(
|
||||||
|
summary=summary,
|
||||||
|
core_revision=revision.core_fingerprint(),
|
||||||
|
progress_revision=revision.progress_fingerprint(),
|
||||||
|
built_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
self.last_error = None
|
||||||
|
|
||||||
|
def _entry_age(self) -> float | None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
if self._entry is None:
|
||||||
|
return None
|
||||||
|
return time.monotonic() - self._entry.built_at
|
||||||
|
|
||||||
|
def _entry_matches(self, revision: SummaryRevision) -> tuple[bool, bool]:
|
||||||
|
if self._entry is None:
|
||||||
|
return False, False
|
||||||
|
core_match = self._entry.core_revision == revision.core_fingerprint()
|
||||||
|
progress_match = self._entry.progress_revision == revision.progress_fingerprint()
|
||||||
|
return core_match, progress_match
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
revision: SummaryRevision,
|
||||||
|
*,
|
||||||
|
force_refresh: bool,
|
||||||
|
) -> tuple[CacheStatus, StateSummary | None]:
|
||||||
|
import time
|
||||||
|
|
||||||
|
if force_refresh:
|
||||||
|
return "miss", None
|
||||||
|
|
||||||
|
if self._entry is None:
|
||||||
|
return "miss", None
|
||||||
|
|
||||||
|
age = self._entry_age()
|
||||||
|
if age is not None and age > _MAX_STALE_AGE_SECONDS:
|
||||||
|
return "miss", None
|
||||||
|
|
||||||
|
core_match, progress_match = self._entry_matches(revision)
|
||||||
|
if core_match and progress_match:
|
||||||
|
return "hit-revision", self._entry.summary
|
||||||
|
|
||||||
|
if core_match and not progress_match:
|
||||||
|
return "progress-section", self._entry.summary
|
||||||
|
|
||||||
|
# Core changed — serve stale full snapshot while refreshing.
|
||||||
|
return "stale", self._entry.summary
|
||||||
|
|
||||||
|
def schedule_refresh(self, revision: SummaryRevision) -> None:
|
||||||
|
if self._build_fn is None:
|
||||||
|
return
|
||||||
|
if self._refresh_task is not None and not self._refresh_task.done():
|
||||||
|
return
|
||||||
|
self._refresh_task = asyncio.create_task(
|
||||||
|
self._refresh_background(revision),
|
||||||
|
name="summary-cache-refresh",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _refresh_background(self, revision: SummaryRevision) -> None:
|
||||||
|
from api.database import async_session_factory
|
||||||
|
|
||||||
|
if self._build_fn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
current = await fetch_summary_revision(session)
|
||||||
|
summary = await self._build_fn(session)
|
||||||
|
self.store(summary, current)
|
||||||
|
except Exception as exc:
|
||||||
|
self.last_error = str(exc)
|
||||||
|
logger.exception("summary cache background refresh failed")
|
||||||
|
|
||||||
|
|
||||||
|
_summary_cache = SummaryCache()
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary_cache() -> SummaryCache:
|
||||||
|
return _summary_cache
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_summary_cache(scope: InvalidateScope = "all") -> None:
|
||||||
|
_summary_cache.invalidate(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_summary_cache_for_tests() -> None:
|
||||||
|
_summary_cache.reset()
|
||||||
|
|
||||||
|
|
||||||
|
_INVALIDATION_REGISTERED = False
|
||||||
|
|
||||||
|
|
||||||
|
def register_summary_cache_invalidation() -> None:
|
||||||
|
"""Clear summary cache when ORM rows that affect summary are written."""
|
||||||
|
global _INVALIDATION_REGISTERED
|
||||||
|
if _INVALIDATION_REGISTERED:
|
||||||
|
return
|
||||||
|
_INVALIDATION_REGISTERED = True
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
def _invalidate_core(*_args: object, **_kwargs: object) -> None:
|
||||||
|
invalidate_summary_cache("core")
|
||||||
|
|
||||||
|
def _invalidate_progress(*_args: object, **_kwargs: object) -> None:
|
||||||
|
invalidate_summary_cache("progress")
|
||||||
|
|
||||||
|
for _name, model in _CORE_TABLES:
|
||||||
|
event.listen(model, "after_insert", _invalidate_core)
|
||||||
|
event.listen(model, "after_update", _invalidate_core)
|
||||||
|
event.listen(model, "after_delete", _invalidate_core)
|
||||||
|
|
||||||
|
event.listen(SBOMSnapshot, "after_insert", _invalidate_core)
|
||||||
|
event.listen(SBOMSnapshot, "after_delete", _invalidate_core)
|
||||||
|
event.listen(ProgressEvent, "after_insert", _invalidate_progress)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_progress_section(
|
||||||
|
session: AsyncSession,
|
||||||
|
summary: StateSummary,
|
||||||
|
revision: SummaryRevision,
|
||||||
|
) -> StateSummary:
|
||||||
|
recent = await fetch_recent_progress(session)
|
||||||
|
merged = merge_summary(summary, recent)
|
||||||
|
cache = get_summary_cache()
|
||||||
|
if cache._entry is not None and cache._entry.core_revision == revision.core_fingerprint():
|
||||||
|
cache._entry.summary = merged
|
||||||
|
cache._entry.progress_revision = revision.progress_fingerprint()
|
||||||
|
else:
|
||||||
|
cache.store(merged, revision)
|
||||||
|
return merged
|
||||||
@@ -41,9 +41,9 @@ def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_workplan_for_workstream(
|
def find_workplan_for_workplan(
|
||||||
repo: ManagedRepo | None,
|
repo: ManagedRepo | None,
|
||||||
workstream_id: uuid.UUID,
|
workplan_id: uuid.UUID,
|
||||||
) -> WorkplanFileRef | None:
|
) -> WorkplanFileRef | None:
|
||||||
repo_path = resolve_repo_path(repo)
|
repo_path = resolve_repo_path(repo)
|
||||||
if repo_path is None:
|
if repo_path is None:
|
||||||
@@ -57,11 +57,15 @@ def find_workplan_for_workstream(
|
|||||||
continue
|
continue
|
||||||
for path in sorted(directory.glob("*.md")):
|
for path in sorted(directory.glob("*.md")):
|
||||||
meta = _frontmatter(path)
|
meta = _frontmatter(path)
|
||||||
if str(meta.get("state_hub_workstream_id", "")).strip().strip('"') == str(workstream_id):
|
file_id = meta.get("state_hub_workplan_id") or meta.get("state_hub_workstream_id")
|
||||||
|
if str(file_id or "").strip().strip('"') == str(workplan_id):
|
||||||
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
|
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
find_workplan_for_workstream = find_workplan_for_workplan
|
||||||
|
|
||||||
|
|
||||||
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
|
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
|
||||||
return _task_block_for_task(path, task_id) is not None
|
return _task_block_for_task(path, task_id) is not None
|
||||||
|
|
||||||
|
|||||||
221
api/services/write_idempotency.py
Normal file
221
api/services/write_idempotency.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from api.database import async_session_factory
|
||||||
|
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||||
|
|
||||||
|
IDEMPOTENCY_HEADER = b"idempotency-key"
|
||||||
|
REPLAY_HEADER = "X-StateHub-Idempotency-Replay"
|
||||||
|
CONFLICT_STATUS = 409
|
||||||
|
DEFAULT_IDEMPOTENCY_TTL_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WriteRouteRule:
|
||||||
|
method: str
|
||||||
|
pattern: str
|
||||||
|
route_class: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
def matches(self, method: str, path: str) -> bool:
|
||||||
|
normalized = path.rstrip("/") or "/"
|
||||||
|
return self.method == method.upper() and re.fullmatch(self.pattern, normalized) is not None
|
||||||
|
|
||||||
|
|
||||||
|
WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
|
||||||
|
WriteRouteRule("POST", r"/progress", "append", "append progress event"),
|
||||||
|
WriteRouteRule("POST", r"/messages", "append", "send agent message"),
|
||||||
|
WriteRouteRule("PATCH", r"/messages/[^/]+/read", "append", "mark known message read"),
|
||||||
|
WriteRouteRule("POST", r"/token-events", "append", "record token event"),
|
||||||
|
WriteRouteRule("POST", r"/token-events/upsert", "append", "upsert token event"),
|
||||||
|
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
|
||||||
|
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
|
||||||
|
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
|
||||||
|
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
|
||||||
|
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
|
||||||
|
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
|
||||||
|
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def route_rule_for(method: str, path: str) -> WriteRouteRule | None:
|
||||||
|
for rule in WRITE_ROUTE_RULES:
|
||||||
|
if rule.matches(method, path):
|
||||||
|
return rule
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def route_class_for(method: str, path: str) -> str | None:
|
||||||
|
rule = route_rule_for(method, path)
|
||||||
|
return rule.route_class if rule else None
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_request_hash(method: str, path: str, query_string: bytes, body: bytes) -> str:
|
||||||
|
try:
|
||||||
|
parsed: Any = json.loads(body.decode("utf-8")) if body else None
|
||||||
|
body_repr = json.dumps(parsed, sort_keys=True, separators=(",", ":"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
body_repr = body.hex()
|
||||||
|
query = query_string.decode("utf-8", errors="replace")
|
||||||
|
seed = f"{method.upper()}\n{path}\n{query}\n{body_repr}".encode("utf-8")
|
||||||
|
return hashlib.sha256(seed).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _header_value(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None:
|
||||||
|
lname = name.lower()
|
||||||
|
for key, value in headers:
|
||||||
|
if key.lower() == lname:
|
||||||
|
return value.decode("utf-8", errors="replace")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_json_response(response: JSONResponse, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
await response(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteIdempotencyMiddleware:
|
||||||
|
"""Replay exact duplicate write requests carrying Idempotency-Key.
|
||||||
|
|
||||||
|
The middleware is intentionally narrow: it only participates on the offline
|
||||||
|
relay allowlist. Non-allowlisted routes keep their normal behavior even if a
|
||||||
|
caller sends an Idempotency-Key header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, *, ttl_days: int = DEFAULT_IDEMPOTENCY_TTL_DAYS) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.ttl_days = ttl_days
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
method = str(scope.get("method", "")).upper()
|
||||||
|
path = str(scope.get("path", ""))
|
||||||
|
rule = route_rule_for(method, path)
|
||||||
|
headers = list(scope.get("headers") or [])
|
||||||
|
key = _header_value(headers, IDEMPOTENCY_HEADER)
|
||||||
|
if rule is None or not key:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
body = await self._read_body(receive)
|
||||||
|
request_hash = canonical_request_hash(method, path, scope.get("query_string", b""), body)
|
||||||
|
source_host = _header_value(headers, b"x-statehub-source-host")
|
||||||
|
source_agent = _header_value(headers, b"x-statehub-source-agent")
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
existing = (await session.execute(
|
||||||
|
select(WriteIdempotencyKey).where(WriteIdempotencyKey.key == key)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
existing.last_seen_at = datetime.now(tz=timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
if existing.request_hash != request_hash:
|
||||||
|
await _send_json_response(
|
||||||
|
JSONResponse(
|
||||||
|
status_code=CONFLICT_STATUS,
|
||||||
|
content={
|
||||||
|
"error": "Idempotency-Key was reused with a different request",
|
||||||
|
"idempotency_key": key,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
scope,
|
||||||
|
self._receive_from_body(body),
|
||||||
|
send,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await _send_json_response(
|
||||||
|
JSONResponse(
|
||||||
|
status_code=existing.response_status,
|
||||||
|
content=existing.response_body,
|
||||||
|
headers={REPLAY_HEADER: "true"},
|
||||||
|
),
|
||||||
|
scope,
|
||||||
|
self._receive_from_body(body),
|
||||||
|
send,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
start_message: Message | None = None
|
||||||
|
body_parts: list[bytes] = []
|
||||||
|
|
||||||
|
async def capture_send(message: Message) -> None:
|
||||||
|
nonlocal start_message
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
start_message = message
|
||||||
|
elif message["type"] == "http.response.body":
|
||||||
|
body_parts.append(message.get("body", b""))
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, self._receive_from_body(body), capture_send)
|
||||||
|
|
||||||
|
if start_message is None:
|
||||||
|
return
|
||||||
|
status = int(start_message.get("status", 500))
|
||||||
|
if status < 200 or status >= 300:
|
||||||
|
return
|
||||||
|
|
||||||
|
response_body_bytes = b"".join(body_parts)
|
||||||
|
try:
|
||||||
|
response_body = json.loads(response_body_bytes.decode("utf-8")) if response_body_bytes else None
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
existing = (await session.execute(
|
||||||
|
select(WriteIdempotencyKey).where(WriteIdempotencyKey.key == key)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
session.add(WriteIdempotencyKey(
|
||||||
|
key=key,
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
route_class=rule.route_class,
|
||||||
|
request_hash=request_hash,
|
||||||
|
response_status=status,
|
||||||
|
response_body=response_body,
|
||||||
|
source_host=source_host,
|
||||||
|
source_agent=source_agent,
|
||||||
|
first_seen_at=now,
|
||||||
|
last_seen_at=now,
|
||||||
|
expires_at=now + timedelta(days=self.ttl_days),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _read_body(receive: Receive) -> bytes:
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] != "http.request":
|
||||||
|
continue
|
||||||
|
chunks.append(message.get("body", b""))
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
break
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _receive_from_body(body: bytes) -> Receive:
|
||||||
|
sent = False
|
||||||
|
|
||||||
|
async def receive() -> Message:
|
||||||
|
nonlocal sent
|
||||||
|
if sent:
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
sent = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||||
"proposed",
|
"proposed",
|
||||||
"ready",
|
"ready",
|
||||||
"active",
|
"active",
|
||||||
@@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
|||||||
"archived",
|
"archived",
|
||||||
)
|
)
|
||||||
|
|
||||||
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
|
LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = {
|
||||||
"todo": "ready",
|
"todo": "ready",
|
||||||
"done": "finished",
|
"done": "finished",
|
||||||
"completed": "finished",
|
"completed": "finished",
|
||||||
"accepted": "finished",
|
"accepted": "finished",
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||||
*CANONICAL_WORKSTREAM_STATUSES,
|
*CANONICAL_WORKPLAN_STATUSES,
|
||||||
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(),
|
*LEGACY_WORKPLAN_STATUS_ALIASES.keys(),
|
||||||
)
|
)
|
||||||
|
|
||||||
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||||
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked")
|
CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||||
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived")
|
CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||||
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
PLANNING_WORKPLAN_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||||
|
|
||||||
|
# Legacy aliases (workstream terminology)
|
||||||
|
CANONICAL_WORKSTREAM_STATUSES = CANONICAL_WORKPLAN_STATUSES
|
||||||
|
LEGACY_WORKSTREAM_STATUS_ALIASES = LEGACY_WORKPLAN_STATUS_ALIASES
|
||||||
|
SUPPORTED_WORKSTREAM_STATUSES = SUPPORTED_WORKPLAN_STATUSES
|
||||||
|
OPEN_WORKSTREAM_STATUSES = OPEN_WORKPLAN_STATUSES
|
||||||
|
CURRENT_WORKSTREAM_STATUSES = CURRENT_WORKPLAN_STATUSES
|
||||||
|
CLOSED_WORKSTREAM_STATUSES = CLOSED_WORKPLAN_STATUSES
|
||||||
|
PLANNING_WORKSTREAM_STATUSES = PLANNING_WORKPLAN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -42,26 +51,38 @@ class ReadyReviewStatus:
|
|||||||
changed_paths: tuple[str, ...] = ()
|
changed_paths: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str:
|
def normalize_workplan_status(status: Any, *, has_started: bool | None = None) -> str:
|
||||||
"""Return the canonical lifecycle status for a stored or legacy value."""
|
"""Return the canonical lifecycle status for a stored or legacy value."""
|
||||||
value = _status_value(status)
|
value = _status_value(status)
|
||||||
if value == "todo" and has_started:
|
if value == "todo" and has_started:
|
||||||
return "active"
|
return "active"
|
||||||
return LEGACY_WORKSTREAM_STATUS_ALIASES.get(value, value)
|
return LEGACY_WORKPLAN_STATUS_ALIASES.get(value, value)
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_workstream_status(status: Any) -> bool:
|
normalize_workstream_status = normalize_workplan_status
|
||||||
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
|
|
||||||
|
|
||||||
|
|
||||||
def is_supported_workstream_status(status: Any) -> bool:
|
def is_canonical_workplan_status(status: Any) -> bool:
|
||||||
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
|
return _status_value(status) in CANONICAL_WORKPLAN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
is_canonical_workstream_status = is_canonical_workplan_status
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_workplan_status(status: Any) -> bool:
|
||||||
|
return _status_value(status) in SUPPORTED_WORKPLAN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
is_supported_workstream_status = is_supported_workplan_status
|
||||||
|
|
||||||
|
|
||||||
|
def workplan_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||||
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
||||||
|
|
||||||
|
|
||||||
|
workstream_has_started = workplan_has_started
|
||||||
|
|
||||||
|
|
||||||
def ready_review_status(
|
def ready_review_status(
|
||||||
repo_dir: str | Path,
|
repo_dir: str | Path,
|
||||||
reviewed_against_commit: Any,
|
reviewed_against_commit: Any,
|
||||||
|
|||||||
@@ -126,11 +126,9 @@ def _detect_domain(project_path: Path) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _check_mcp() -> bool:
|
def _check_mcp() -> bool:
|
||||||
claude_json = Path.home() / ".claude.json"
|
from scripts.mcp_registration import load_claude_json, mcp_server_registered
|
||||||
if not claude_json.exists():
|
|
||||||
return False
|
return mcp_server_registered(load_claude_json())
|
||||||
config = json.loads(claude_json.read_text())
|
|
||||||
return "state-hub" in config.get("mcpServers", {})
|
|
||||||
|
|
||||||
|
|
||||||
# ── Subcommands ────────────────────────────────────────────────────────────────
|
# ── Subcommands ────────────────────────────────────────────────────────────────
|
||||||
@@ -193,7 +191,8 @@ def cmd_register(args: argparse.Namespace) -> None:
|
|||||||
if _check_mcp():
|
if _check_mcp():
|
||||||
print(" MCP OK")
|
print(" MCP OK")
|
||||||
else:
|
else:
|
||||||
print("WARNING: 'state-hub' not in ~/.claude.json.")
|
print("WARNING: 'dev-hub' (or legacy 'state-hub') not in ~/.claude.json.")
|
||||||
|
print(" Run: python scripts/migrate_mcp_config.py # if upgrading legacy config")
|
||||||
print(" See ~/.claude/CLAUDE.md → MCP Server Registration section.")
|
print(" See ~/.claude/CLAUDE.md → MCP Server Registration section.")
|
||||||
|
|
||||||
# ── Step 5: Write CLAUDE.custodian.md ─────────────────────────────────────
|
# ── Step 5: Write CLAUDE.custodian.md ─────────────────────────────────────
|
||||||
@@ -466,6 +465,55 @@ def cmd_status(_args: argparse.Namespace) -> None:
|
|||||||
print(f" [{deadline}] {d['title']}")
|
print(f" [{deadline}] {d['title']}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _outbox_store(args):
|
||||||
|
from api.edge.outbox import OutboxStore, default_outbox_path
|
||||||
|
|
||||||
|
return OutboxStore(args.outbox_path or default_outbox_path())
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_outbox_status(args: argparse.Namespace) -> None:
|
||||||
|
store = _outbox_store(args)
|
||||||
|
print(json.dumps(store.summary(), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_outbox_list(args: argparse.Namespace) -> None:
|
||||||
|
store = _outbox_store(args)
|
||||||
|
rows = store.export(status=args.status, limit=args.limit)
|
||||||
|
print(json.dumps(rows, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_outbox_export(args: argparse.Namespace) -> None:
|
||||||
|
store = _outbox_store(args)
|
||||||
|
payload = store.export(status=args.status, limit=args.limit)
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
print(f"Exported {len(payload)} envelope(s) to {args.output}")
|
||||||
|
else:
|
||||||
|
print(json.dumps(payload, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_outbox_replay(args: argparse.Namespace) -> None:
|
||||||
|
import asyncio
|
||||||
|
from api.edge.relay import replay_pending
|
||||||
|
|
||||||
|
store = _outbox_store(args)
|
||||||
|
upstream = args.upstream_url or os.environ.get("STATEHUB_UPSTREAM_URL") or API_BASE
|
||||||
|
result = asyncio.run(replay_pending(store, upstream_url=upstream, limit=args.limit))
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_outbox_retry(args: argparse.Namespace) -> None:
|
||||||
|
store = _outbox_store(args)
|
||||||
|
store.retry(args.envelope_id)
|
||||||
|
print(f"Queued {args.envelope_id} for retry")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_outbox_cancel(args: argparse.Namespace) -> None:
|
||||||
|
store = _outbox_store(args)
|
||||||
|
store.cancel(args.envelope_id)
|
||||||
|
print(f"Cancelled {args.envelope_id}")
|
||||||
|
|
||||||
# ── Entry point ────────────────────────────────────────────────────────────────
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -550,12 +598,47 @@ def main() -> None:
|
|||||||
ctask.add_argument("--assignee", default=None)
|
ctask.add_argument("--assignee", default=None)
|
||||||
ctask.add_argument("--description", default=None)
|
ctask.add_argument("--description", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
# outbox
|
||||||
|
outbox = sub.add_parser("outbox", help="Inspect and replay the local State Hub edge outbox")
|
||||||
|
outbox.add_argument("--outbox-path", default=None, help="SQLite outbox path (defaults to ~/.statehub/edge-outbox.sqlite3)")
|
||||||
|
out_sub = outbox.add_subparsers(dest="outbox_command", required=True)
|
||||||
|
|
||||||
|
out_status = out_sub.add_parser("status", help="Show pending, conflict, and ack counts")
|
||||||
|
out_status.set_defaults(func=cmd_outbox_status)
|
||||||
|
|
||||||
|
out_list = out_sub.add_parser("list", help="List outbox envelopes as JSON")
|
||||||
|
out_list.add_argument("--status", default=None, help="Filter by status")
|
||||||
|
out_list.add_argument("--limit", type=int, default=100)
|
||||||
|
out_list.set_defaults(func=cmd_outbox_list)
|
||||||
|
|
||||||
|
out_export = out_sub.add_parser("export", help="Export non-secret envelopes")
|
||||||
|
out_export.add_argument("--status", default=None, help="Filter by status")
|
||||||
|
out_export.add_argument("--limit", type=int, default=1000)
|
||||||
|
out_export.add_argument("--output", default=None, help="Write JSON to a file instead of stdout")
|
||||||
|
out_export.set_defaults(func=cmd_outbox_export)
|
||||||
|
|
||||||
|
out_replay = out_sub.add_parser("replay", help="Replay due queued envelopes")
|
||||||
|
out_replay.add_argument("--upstream-url", default=None, help="Central State Hub API base URL")
|
||||||
|
out_replay.add_argument("--limit", type=int, default=50)
|
||||||
|
out_replay.set_defaults(func=cmd_outbox_replay)
|
||||||
|
|
||||||
|
out_retry = out_sub.add_parser("retry", help="Force one envelope back to queued")
|
||||||
|
out_retry.add_argument("envelope_id")
|
||||||
|
out_retry.set_defaults(func=cmd_outbox_retry)
|
||||||
|
|
||||||
|
out_cancel = out_sub.add_parser("cancel", help="Cancel one envelope")
|
||||||
|
out_cancel.add_argument("envelope_id")
|
||||||
|
out_cancel.set_defaults(func=cmd_outbox_cancel)
|
||||||
|
|
||||||
# status
|
# status
|
||||||
sub.add_parser("status", help="Show State Hub health and summary totals")
|
sub.add_parser("status", help="Show State Hub health and summary totals")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "register":
|
if hasattr(args, "func"):
|
||||||
|
args.func(args)
|
||||||
|
elif args.command == "register":
|
||||||
run_statehub_register(args)
|
run_statehub_register(args)
|
||||||
elif args.command == "register-project":
|
elif args.command == "register-project":
|
||||||
cmd_register(args)
|
cmd_register(args)
|
||||||
|
|||||||
@@ -34,7 +34,16 @@ export default {
|
|||||||
{ name: "Inbox", path: "/inbox" },
|
{ name: "Inbox", path: "/inbox" },
|
||||||
{ name: "Progress", path: "/progress" },
|
{ name: "Progress", path: "/progress" },
|
||||||
{ name: "Token Cost", path: "/token-cost" },
|
{ name: "Token Cost", path: "/token-cost" },
|
||||||
{ name: "Services (TPSC)", path: "/tpsc" },
|
{
|
||||||
|
name: "Services",
|
||||||
|
collapsible: true,
|
||||||
|
open: false,
|
||||||
|
pages: [
|
||||||
|
{ name: "Third Party", path: "/tpsc" },
|
||||||
|
{ name: "First Party", path: "/services/first-party" },
|
||||||
|
{ name: "Self Hosted", path: "/services/self-hosted" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ name: "Todo", path: "/todo" },
|
{ name: "Todo", path: "/todo" },
|
||||||
{ name: "Tools & Apps", path: "/tools" },
|
{ name: "Tools & Apps", path: "/tools" },
|
||||||
// ── Sections (alphabetical) ───────────────────────────────────────────────
|
// ── Sections (alphabetical) ───────────────────────────────────────────────
|
||||||
@@ -104,6 +113,7 @@ export default {
|
|||||||
{ name: "Repos", path: "/docs/repos" },
|
{ name: "Repos", path: "/docs/repos" },
|
||||||
{ name: "SBOM", path: "/docs/sbom" },
|
{ name: "SBOM", path: "/docs/sbom" },
|
||||||
{ name: "SCOPE.md", path: "/docs/scope" },
|
{ name: "SCOPE.md", path: "/docs/scope" },
|
||||||
|
{ name: "Service Catalog", path: "/docs/services" },
|
||||||
{ name: "Tasks", path: "/docs/tasks" },
|
{ name: "Tasks", path: "/docs/tasks" },
|
||||||
{ name: "TPSC", path: "/docs/tpsc" },
|
{ name: "TPSC", path: "/docs/tpsc" },
|
||||||
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
|
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
|
||||||
|
|||||||
@@ -4,27 +4,36 @@ title: Domains — Reference
|
|||||||
|
|
||||||
# Domains — Reference
|
# Domains — Reference
|
||||||
|
|
||||||
The Domains page shows all registered project domains and the repositories
|
The Domains page shows the **14 fixed market domains** from the Repo
|
||||||
associated with each one. Domains are the top-level organisational unit of the
|
Classification Standard. These replaced the old ad-hoc coordination domains
|
||||||
Custodian ecosystem.
|
(custodian, railiance, markitect, …) in STATE-WP-0065.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is a domain?
|
## What is a domain?
|
||||||
|
|
||||||
A domain corresponds to one of the six tracked project areas:
|
A domain is an intended **market / user segment** — not a project org unit.
|
||||||
|
Each registered repo has exactly one primary domain (from its
|
||||||
|
`.repo-classification.yaml`), stored on `managed_repos.domain_id`.
|
||||||
|
|
||||||
| Slug | Project |
|
| Slug | Segment |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `custodian` | The Custodian agent system itself |
|
| `infotech` | Developers, platforms, internal tooling users |
|
||||||
| `railiance` | DevOps & infrastructure reliability |
|
| `financials` | Finance, trading, payments |
|
||||||
| `markitect` | Knowledge artifact management |
|
| `communication` | Messaging, social, collaboration |
|
||||||
| `coulomb_social` | Co-creation marketplace |
|
| `consumer` | General consumers |
|
||||||
| `personhood` | Rights & obligations framework |
|
| `health` | Healthcare, wellness |
|
||||||
| `foerster_capabilities` | Agency capability taxonomy |
|
| `industrials` | Manufacturing, logistics |
|
||||||
|
| `energy` | Energy sector |
|
||||||
|
| `utilities` | Utilities infrastructure |
|
||||||
|
| `materials` | Materials / commodities |
|
||||||
|
| `realestate` | Property, housing |
|
||||||
|
| `crypto` | Crypto / web3 |
|
||||||
|
| `agents` | AI-native agent users |
|
||||||
|
| `space` | Space industry |
|
||||||
|
| `government` | Civic, public sector |
|
||||||
|
|
||||||
Each domain has a slug (URL-friendly identifier), a human-readable name, an
|
Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md`
|
||||||
optional description, and a status.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,63 +41,21 @@ optional description, and a status.
|
|||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| **active** | Live domain — topics, workstreams, and tasks are being tracked |
|
| **active** | Live domain — repos and workplans may reference it |
|
||||||
| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist |
|
| **archived** | Retired; no new registrations |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## KPI row
|
## Relationship to repos and workplans
|
||||||
|
|
||||||
Four counters at the top of the page:
|
- **Repos** are the primary anchor — classification file is source of truth.
|
||||||
|
- **Workplans** require `repo_id`; market domain is derived from the repo.
|
||||||
| Counter | Meaning |
|
- **Topics** are optional legacy tags; workplan frontmatter `domain:` may still
|
||||||
|---------|---------|
|
use old coordination slugs — the consistency checker maps these to market domains.
|
||||||
| Total domains | All registered domains regardless of status |
|
|
||||||
| Active | Domains with status `active` |
|
|
||||||
| Total repos | Sum of all registered repositories across all domains |
|
|
||||||
| Newest domain | Name of the most recently created domain |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Domain cards
|
## Related
|
||||||
|
|
||||||
One card per domain showing:
|
- **[Repos](/docs/repos)** — portfolio view with category / capability filters
|
||||||
|
- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file
|
||||||
- **Slug** — monospace identifier
|
|
||||||
- **Status badge** — green `active` or grey `archived`
|
|
||||||
- **Name** — display name
|
|
||||||
- **Description** — first 160 characters
|
|
||||||
- **Repos** — list of registered repositories for this domain, each showing name, local path, and remote URL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RecentlyOnScope
|
|
||||||
|
|
||||||
The `Domains / RecentlyOnScope` page generates deterministic Markdown digests
|
|
||||||
for a selected domain. The range parameter defaults to `1h` and accepts compact
|
|
||||||
durations such as `15m`, `6h`, or `1d`.
|
|
||||||
|
|
||||||
Generated reports are written under the configured State Hub report directory,
|
|
||||||
defaulting to `reports/recently-on-scope/<domain_slug>/`. The dashboard lists
|
|
||||||
those Markdown files and previews the raw report content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managing domains
|
|
||||||
|
|
||||||
Via MCP:
|
|
||||||
|
|
||||||
```
|
|
||||||
create_domain(slug="my_project", name="My Project", description="…")
|
|
||||||
rename_domain(slug="old_slug", new_slug="new_slug", new_name="New Name")
|
|
||||||
archive_domain(slug="my_project") # fails if active topics exist
|
|
||||||
```
|
|
||||||
|
|
||||||
Via Makefile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make add-domain SLUG=my_project NAME="My Project"
|
|
||||||
make rename-domain OLD_SLUG=my_project NEW_SLUG=myproject NEW_NAME="My Project"
|
|
||||||
```
|
|
||||||
|
|
||||||
*Domains are never hard-deleted — only archived.*
|
|
||||||
@@ -59,6 +59,11 @@ make api # db + migrate + uvicorn (restarts if already running)
|
|||||||
|
|
||||||
All endpoints are read-only GET requests. The dashboard never writes to the API.
|
All endpoints are read-only GET requests. The dashboard never writes to the API.
|
||||||
|
|
||||||
|
`/state/summary` is revision-cached server-side. Repeated polls with unchanged
|
||||||
|
hub data return `X-StateHub-Cache: hit-revision` without rebuilding the full
|
||||||
|
snapshot. Prefer `/state/overview` on the Overview page (lighter bounded
|
||||||
|
read model).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Poll interval: 15 s for most pages, 60 s for Overview. Data is refreshed in the background — the page never reloads itself.*
|
*Poll interval: 15 s for most pages, 60 s for Overview. Data is refreshed in the background — the page never reloads itself.*
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ title: Repos — Reference
|
|||||||
# Repos — Reference
|
# Repos — Reference
|
||||||
|
|
||||||
The Repos page shows every repository registered in the Custodian ecosystem,
|
The Repos page shows every repository registered in the Custodian ecosystem,
|
||||||
their SBOM ingestion status, and a domain-grouped coverage map.
|
their **classification** (category, market domain, capabilities, business stake),
|
||||||
|
SBOM ingestion status, and a domain-grouped coverage map.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is a managed repo?
|
## What is a managed repo?
|
||||||
|
|
||||||
A managed repo is a git repository that has been registered with the state hub
|
A managed repo is a git repository registered with State Hub. Registration is
|
||||||
via `custodian register-project` or `register_repo()`. Registration records the
|
**classification-driven**:
|
||||||
repo's slug, domain, local path, and optional remote URL. Once registered, the
|
|
||||||
repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding
|
1. Commit `.repo-classification.yaml` per the Repo Classification Standard.
|
||||||
workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion
|
2. Run `make register-from-classification REPO=<slug>` (or use the MCP tool
|
||||||
and the ADR-001 workplan validator.
|
`register_repo_from_classification`).
|
||||||
|
|
||||||
|
The file is the source of truth; the hub stores a validated copy on
|
||||||
|
`managed_repos` (category, domain, capability_tags, business_stake, provenance).
|
||||||
|
|
||||||
|
Legacy `custodian register-project` still works for agent onboarding but should
|
||||||
|
be followed by classification registration.
|
||||||
|
|
||||||
For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**.
|
For the full onboarding journey see **[Repo Integration](/docs/repo-integration)**.
|
||||||
|
|
||||||
@@ -27,69 +34,56 @@ For the full onboarding journey see **[Repo Integration](/docs/repo-integration)
|
|||||||
| Card | Meaning |
|
| Card | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| **Registered Repos** | Active repos only (status = active) |
|
| **Registered Repos** | Active repos only (status = active) |
|
||||||
| **Domains** | Count of distinct domain slugs across registered repos |
|
| **Market Domains** | Distinct primary domains across registered repos |
|
||||||
|
| **Categories** | Distinct work categories (experimental, tooling, product, …) |
|
||||||
| **SBOM Ingested** | Repos with at least one SBOM snapshot |
|
| **SBOM Ingested** | Repos with at least one SBOM snapshot |
|
||||||
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
|
| **SBOM Gaps** | Repos with no ingested SBOM — red border when > 0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Coverage Map
|
## Portfolio by Category
|
||||||
|
|
||||||
Groups repos by domain. Each domain block shows:
|
Groups repos by `category` (experimental, research, project, tooling, product,
|
||||||
|
business). Each block shows domain, capabilities, business stake, and who
|
||||||
- **Domain name** with SBOM, EP, and TD chip indicators
|
classified the repo (`human` vs `migration`).
|
||||||
- **SBOM chip** — green ✓ if all repos in the domain are ingested, amber ⚠ if any gap exists
|
|
||||||
- **EPs chip** — count of open/in-progress extension points for this domain
|
|
||||||
- **TDs chip** — count of open/in-progress technical debt items for this domain
|
|
||||||
- **Repo table** — one row per repo with SBOM status, package count, and local path
|
|
||||||
|
|
||||||
Rows with no SBOM are highlighted in amber.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Filters
|
## Coverage Map
|
||||||
|
|
||||||
|
Groups repos by **market domain**. Each domain block shows SBOM, EP, and TD
|
||||||
|
chips plus per-repo classification columns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filters (All Repos Table)
|
||||||
|
|
||||||
| Filter | Effect |
|
| Filter | Effect |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Domain** | Show repos for a single domain only |
|
| **Market domain** | Primary domain slug |
|
||||||
| **Gaps only** | Toggle to show only repos without an ingested SBOM |
|
| **Category** | Repo work category |
|
||||||
|
| **Capability** | Repos tagged with a capability |
|
||||||
|
| **Business stake** | Repos affecting a business responsibility area |
|
||||||
|
| **DoI tier** | Definition of Integrated tier |
|
||||||
|
| **Gaps only** | Repos without ingested SBOM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consistency (C-24)
|
||||||
|
|
||||||
|
The ADR-001 consistency checker warns when a registered repo lacks a valid
|
||||||
|
`.repo-classification.yaml` on disk. Migration-derived rows (`classified_by:
|
||||||
|
migration`) get an explanatory note until a human-reviewed file is committed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Onboarding a new repo
|
## Onboarding a new repo
|
||||||
|
|
||||||
See **[Repo Integration](/docs/repo-integration)** for the full journey.
|
Use the **Add Repo** form or:
|
||||||
|
|
||||||
Quick reference:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks
|
# 1. Author classification file in the repo
|
||||||
custodian register-project --domain <slug>
|
# 2. Register / reclassify
|
||||||
```
|
make register-from-classification PATH=/path/to/repo
|
||||||
|
make fix-consistency REPO=<slug>
|
||||||
## Ingesting a repo's SBOM
|
```
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auto-detects lockfile at repo root
|
|
||||||
cd ~/state-hub
|
|
||||||
make ingest-sbom REPO=<slug> REPO_PATH=/absolute/path
|
|
||||||
|
|
||||||
# Multi-ecosystem repo — scan all lockfiles recursively
|
|
||||||
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/absolute/path
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported lockfile formats: `uv.lock`, `requirements.txt`, `package-lock.json`,
|
|
||||||
`yarn.lock`, `Cargo.lock`, `.terraform.lock.hcl`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infra-only repos
|
|
||||||
|
|
||||||
Repos with no lockfile (Ansible, shell scripts) can be registered for inventory
|
|
||||||
purposes. The SBOM gap is expected and can be left as-is. Terraform providers
|
|
||||||
are auto-detected via `.terraform.lock.hcl` when using `--scan`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*SBOM snapshots are replaced on each ingest — not appended. The last ingestion
|
|
||||||
timestamp is recorded on the managed_repo row.*
|
|
||||||
54
dashboard/src/docs/services.md
Normal file
54
dashboard/src/docs/services.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
title: Service Catalog — Reference
|
||||||
|
---
|
||||||
|
|
||||||
|
# Service Catalog (two dimensions)
|
||||||
|
|
||||||
|
Every service coulomb consumes or operates is classified along **two independent
|
||||||
|
dimensions**, so four classes fall out of their product:
|
||||||
|
|
||||||
|
| | **third-party** (not dev-responsible) | **first-party** (dev-responsible) |
|
||||||
|
|---|---|---|
|
||||||
|
| **cloud-hosted** (consumed) | SaaS / APIs — the classic [TPSC](/docs/tpsc) | a coulomb service deployed to a cloud |
|
||||||
|
| **self-hosted** (operated) | OSS coulomb runs (Gitea, Postgres…) | a coulomb service on coulomb infra |
|
||||||
|
|
||||||
|
- **Hosting** — `self_hosted` (coulomb operates the service) vs `cloud_hosted`
|
||||||
|
(coulomb consumes someone else's running service).
|
||||||
|
- **Development** — `first_party` (coulomb is development-responsible) vs
|
||||||
|
`third_party` (coulomb is not).
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
A common **`service_catalog`** core table holds the shared fields
|
||||||
|
(`slug`, `name`, `owner_or_provider`, `category`, `status`, `hosting_type`,
|
||||||
|
`development_type`, `maturity_level`). Dimension-specific data lives in 1:1
|
||||||
|
extension tables that **compose** — a self-hosted first-party service carries
|
||||||
|
both the self-hosted *and* first-party extensions:
|
||||||
|
|
||||||
|
| Extension | Keyed on | Holds |
|
||||||
|
|---|---|---|
|
||||||
|
| `service_third_party` | `development_type = third_party` | upstream packages, support/service contacts, source, license, pricing |
|
||||||
|
| `service_first_party` | `development_type = first_party` | internal dev repo (`managed_repos` FK), owning domain |
|
||||||
|
| `service_cloud` | `hosting_type = cloud_hosted` | GDPR maturity, DPA, ToS/privacy, data-processing regions, retention |
|
||||||
|
| `service_self_hosted` | `hosting_type = self_hosted` | three-helix instance/host, deployment & runbook refs, upstream OSS project |
|
||||||
|
|
||||||
|
`maturity_level` (1 · Core → 2 · Standard → 3 · Mature) tracks a service against
|
||||||
|
the [Service DoM](/policy/service-dom).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /services/catalog?hosting_type=&development_type=&maturity_level=&status=`
|
||||||
|
— filtered list; each row includes its applicable extensions.
|
||||||
|
- `GET /services/{slug}` — one service with extensions.
|
||||||
|
- `POST /services/catalog` — upsert by slug; pass `first_party.repo_slug` to link
|
||||||
|
the internal dev repo.
|
||||||
|
|
||||||
|
The dashboard **Services** section renders three views over this catalog:
|
||||||
|
[Third Party](/tpsc), [First Party](/services/first-party), and
|
||||||
|
[Self Hosted](/services/self-hosted).
|
||||||
|
|
||||||
|
## Migration & back-compat
|
||||||
|
|
||||||
|
Existing TPSC catalog rows migrated into `service_catalog` as
|
||||||
|
`(cloud_hosted, third_party)`, reusing their ids so `tpsc_entries.catalog_id`
|
||||||
|
keep resolving. The `/tpsc/*` endpoints and `tpsc.yaml` ingestion are unchanged.
|
||||||
@@ -7,6 +7,11 @@ title: Third-Party Services Catalog (TPSC)
|
|||||||
The TPSC tracks external service dependencies (APIs, SaaS, CLIs) across all
|
The TPSC tracks external service dependencies (APIs, SaaS, CLIs) across all
|
||||||
registered repos — complementing the SBOM for package dependencies.
|
registered repos — complementing the SBOM for package dependencies.
|
||||||
|
|
||||||
|
> **Now part of the broader service catalog.** TPSC is the `cloud_hosted` +
|
||||||
|
> `third_party` quadrant of the two-dimension [service catalog](/docs/services).
|
||||||
|
> Catalog rows have migrated into `service_catalog`; the `/tpsc/*` endpoints and
|
||||||
|
> per-repo `tpsc.yaml` dependency snapshots continue to work unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why TPSC?
|
## Why TPSC?
|
||||||
|
|||||||
@@ -102,14 +102,28 @@ const repoRows = repos
|
|||||||
const integrating = !!integratingBySlug[r.slug];
|
const integrating = !!integratingBySlug[r.slug];
|
||||||
const doiEntry = doiBySlug[r.slug] ?? null;
|
const doiEntry = doiBySlug[r.slug] ?? null;
|
||||||
const doiTier = doiEntry?.tier ?? "none";
|
const doiTier = doiEntry?.tier ?? "none";
|
||||||
|
const category = r.category ?? "—";
|
||||||
|
const capList = r.capability_tags ?? [];
|
||||||
|
const stakeList = r.business_stake ?? [];
|
||||||
|
const capTags = capList.length
|
||||||
|
? capList.slice(0, 3).join(", ") + (capList.length > 3 ? "…" : "")
|
||||||
|
: "—";
|
||||||
|
const classifiedBy = r.classified_by ?? "—";
|
||||||
return {
|
return {
|
||||||
_id: r.id,
|
_id: r.id,
|
||||||
_domSlug: domSlug,
|
_domSlug: domSlug,
|
||||||
|
_category: category,
|
||||||
|
_capList: capList,
|
||||||
|
_stakeList: stakeList,
|
||||||
_hasSbom: hasSbom,
|
_hasSbom: hasSbom,
|
||||||
_integrating: integrating,
|
_integrating: integrating,
|
||||||
_doiTier: doiTier,
|
_doiTier: doiTier,
|
||||||
repo: r.slug,
|
repo: r.slug,
|
||||||
domain: domName,
|
domain: domName,
|
||||||
|
category: category,
|
||||||
|
capTags: capTags,
|
||||||
|
businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
|
||||||
|
classifiedBy: classifiedBy,
|
||||||
status: integrating ? "⚙ integrating" : "ready",
|
status: integrating ? "⚙ integrating" : "ready",
|
||||||
path: r.local_path ?? "—",
|
path: r.local_path ?? "—",
|
||||||
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
||||||
@@ -153,9 +167,13 @@ display(html`<div class="kpi-row">
|
|||||||
<p class="big-num">${repoRows.length}</p>
|
<p class="big-num">${repoRows.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Domains</h3>
|
<h3>Market Domains</h3>
|
||||||
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Categories</h3>
|
||||||
|
<p class="big-num">${new Set(repoRows.map(r => r._category).filter(c => c !== "—")).size}</p>
|
||||||
|
</div>
|
||||||
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
|
<div class="card ${integratingCount > 0 ? 'card-integrating' : ''}">
|
||||||
<h3>Integrating</h3>
|
<h3>Integrating</h3>
|
||||||
<p class="big-num">${integratingCount}</p>
|
<p class="big-num">${integratingCount}</p>
|
||||||
@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) {
|
|||||||
<table class="repo-table">
|
<table class="repo-table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Repo</th>
|
<th>Repo</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Capabilities</th>
|
||||||
<th>DoI Tier</th>
|
<th>DoI Tier</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>SBOM</th>
|
<th>SBOM</th>
|
||||||
@@ -249,6 +269,8 @@ if (domainBlocks.length === 0) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
|
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
|
||||||
<td class="repo-cell"><code>${r.repo}</code></td>
|
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||||
|
<td>${r.category}</td>
|
||||||
|
<td class="path-cell" title=${r.capTags}>${r.capTags}</td>
|
||||||
<td>${_doiBadge(r._doiTier)}</td>
|
<td>${_doiBadge(r._doiTier)}</td>
|
||||||
<td>${r._integrating
|
<td>${r._integrating
|
||||||
? html`<span class="chip chip-integrating">⚙ integrating</span>`
|
? html`<span class="chip chip-integrating">⚙ integrating</span>`
|
||||||
@@ -266,25 +288,76 @@ if (domainBlocks.length === 0) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Portfolio by Category
|
||||||
|
|
||||||
|
```js
|
||||||
|
const byCategory = {};
|
||||||
|
for (const r of repoRows) {
|
||||||
|
const key = r._category === "—" ? "unclassified" : r._category;
|
||||||
|
(byCategory[key] = byCategory[key] ?? []).push(r);
|
||||||
|
}
|
||||||
|
const categoryBlocks = Object.entries(byCategory).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
if (categoryBlocks.length > 0) {
|
||||||
|
display(html`<h2 style="margin-top:2rem">Portfolio by Category</h2>
|
||||||
|
<div class="domain-list">
|
||||||
|
${categoryBlocks.map(([cat, rows]) => html`
|
||||||
|
<div class="domain-block">
|
||||||
|
<div class="domain-header">
|
||||||
|
<span class="domain-name">${cat}</span>
|
||||||
|
<span class="domain-chips">
|
||||||
|
<span class="chip chip-neutral">${rows.length} repo(s)</span>
|
||||||
|
<span class="chip chip-neutral">${new Set(rows.map(r => r._domSlug)).size} domain(s)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="repo-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Repo</th><th>Domain</th><th>Capabilities</th><th>Business stake</th><th>Classified</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${rows.map(r => html`<tr>
|
||||||
|
<td class="repo-cell"><code>${r.repo}</code></td>
|
||||||
|
<td>${r.domain}</td>
|
||||||
|
<td class="path-cell">${r.capTags}</td>
|
||||||
|
<td class="path-cell">${r.businessStake}</td>
|
||||||
|
<td>${r.classifiedBy}</td>
|
||||||
|
</tr>`)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## All Repos Table
|
## All Repos Table
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", value: "all"});
|
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"});
|
||||||
const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
|
const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
|
||||||
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
|
const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
|
||||||
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${doiFilter}${gapFilter}</div>`);
|
const stakeFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._stakeList)).values()].sort(), {label: "Business stake", value: "all"});
|
||||||
|
const doiFilter = Inputs.select(["all", "none", "core", "standard", "full"], {label: "DoI tier", value: "all"});
|
||||||
|
const gapFilter = Inputs.toggle({label: "Gaps only (no SBOM)", value: false});
|
||||||
|
display(html`<div class="filter-bar">${domainFilter}${categoryFilter}${capabilityFilter}${stakeFilter}${doiFilter}${gapFilter}</div>`);
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const filteredRows = repoRows.filter(r =>
|
const filteredRows = repoRows.filter(r =>
|
||||||
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
|
(domainFilter.value === "all" || r._domSlug === domainFilter.value) &&
|
||||||
(doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
|
(categoryFilter.value === "all" || r._category === categoryFilter.value) &&
|
||||||
|
(capabilityFilter.value === "all" || r._capList.includes(capabilityFilter.value)) &&
|
||||||
|
(stakeFilter.value === "all" || r._stakeList.includes(stakeFilter.value)) &&
|
||||||
|
(doiFilter.value === "all" || r._doiTier === doiFilter.value) &&
|
||||||
(!gapFilter.value || !r._hasSbom)
|
(!gapFilter.value || !r._hasSbom)
|
||||||
);
|
);
|
||||||
|
|
||||||
display(Inputs.table(filteredRows.map(r => ({
|
display(Inputs.table(filteredRows.map(r => ({
|
||||||
Repo: r.repo,
|
Repo: r.repo,
|
||||||
Domain: r.domain,
|
Domain: r.domain,
|
||||||
|
Category: r.category,
|
||||||
|
Capabilities: r.capTags,
|
||||||
|
"Business stake": r.businessStake,
|
||||||
|
Classified: r.classifiedBy,
|
||||||
"DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
|
"DoI Tier": DOI_TIER_LABEL[r._doiTier] ?? r._doiTier,
|
||||||
Status: r.status,
|
Status: r.status,
|
||||||
SBOM: r.sbom,
|
SBOM: r.sbom,
|
||||||
|
|||||||
49
dashboard/src/services/first-party.md
Normal file
49
dashboard/src/services/first-party.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: First Party Services
|
||||||
|
---
|
||||||
|
|
||||||
|
# First Party Services Catalog
|
||||||
|
|
||||||
|
Services **coulomb is development-responsible for** (`development_type = first_party`),
|
||||||
|
whether deployed to a cloud or self-hosted on coulomb infrastructure. The
|
||||||
|
**Service Maturity Level** column tracks each service against the
|
||||||
|
[Service DoM](/policy/service-dom) (1 · Core → 2 · Standard → 3 · Mature).
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {API} from "../components/config.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const services = await fetch(`${API}/services/catalog?development_type=first_party`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.catch(() => []);
|
||||||
|
const repos = await fetch(`${API}/repos/`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.catch(() => []);
|
||||||
|
const repoById = new Map(repos.map(r => [r.id, r.slug]));
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const LEVEL = {1: "1 · Core", 2: "2 · Standard", 3: "3 · Mature"};
|
||||||
|
|
||||||
|
const rows = services.map(s => ({
|
||||||
|
Service: s.name,
|
||||||
|
Slug: s.slug,
|
||||||
|
Hosting: s.hosting_type === "self_hosted" ? "self-hosted" : "cloud-hosted",
|
||||||
|
"Maturity Level": s.maturity_level ? LEVEL[s.maturity_level] : "—",
|
||||||
|
"Dev Repo": s.first_party?.repo_id ? (repoById.get(s.first_party.repo_id) ?? "(unlinked)") : "—",
|
||||||
|
Domain: s.first_party?.owning_domain ?? "—",
|
||||||
|
Status: s.status,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(services.length === 0
|
||||||
|
? html`<div style="color:#64748b;padding:1rem;">No first-party services registered yet. Add one with
|
||||||
|
<code>POST /services/catalog</code> (<code>development_type: "first_party"</code>).</div>`
|
||||||
|
: Inputs.table(rows, {
|
||||||
|
columns: ["Service", "Hosting", "Maturity Level", "Dev Repo", "Domain", "Status"],
|
||||||
|
sort: "Service",
|
||||||
|
rows: 30,
|
||||||
|
}));
|
||||||
|
```
|
||||||
48
dashboard/src/services/self-hosted.md
Normal file
48
dashboard/src/services/self-hosted.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: Self Hosted Services
|
||||||
|
---
|
||||||
|
|
||||||
|
# Self Hosted Services Catalog
|
||||||
|
|
||||||
|
Services and webapps built on **third-party / open-source software** that coulomb
|
||||||
|
**hosts and operates** as part of the three-helix infrastructure
|
||||||
|
(`hosting_type = self_hosted`, `development_type = third_party`). coulomb runs
|
||||||
|
these but is not development-responsible for them.
|
||||||
|
|
||||||
|
> First-party services that coulomb also self-hosts (e.g. the State Hub itself)
|
||||||
|
> are listed under [First Party](/services/first-party), classified by who develops
|
||||||
|
> them.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {API} from "../components/config.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const services = await fetch(`${API}/services/catalog?hosting_type=self_hosted&development_type=third_party`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.catch(() => []);
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const rows = services.map(s => ({
|
||||||
|
Service: s.name,
|
||||||
|
Slug: s.slug,
|
||||||
|
"Upstream OSS": s.self_hosted?.upstream_oss_project ?? s.owner_or_provider ?? "—",
|
||||||
|
"Helix Instance": s.self_hosted?.helix_instance ?? "—",
|
||||||
|
Host: s.self_hosted?.host_node ?? "—",
|
||||||
|
Runbook: s.self_hosted?.runbook_ref ?? "—",
|
||||||
|
Status: s.status,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
display(services.length === 0
|
||||||
|
? html`<div style="color:#64748b;padding:1rem;">No self-hosted third-party services registered yet. Add one with
|
||||||
|
<code>POST /services/catalog</code> (<code>hosting_type: "self_hosted"</code>,
|
||||||
|
<code>development_type: "third_party"</code>).</div>`
|
||||||
|
: Inputs.table(rows, {
|
||||||
|
columns: ["Service", "Upstream OSS", "Helix Instance", "Host", "Runbook", "Status"],
|
||||||
|
sort: "Service",
|
||||||
|
rows: 30,
|
||||||
|
}));
|
||||||
|
```
|
||||||
90
deploy/railiance/README.md
Normal file
90
deploy/railiance/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# State Hub Railiance Deployment Handoff
|
||||||
|
|
||||||
|
This directory contains the State Hub deployment handoff for `CUST-WP-0011`.
|
||||||
|
It is source-owned by `state-hub` and split along the Railiance ownership
|
||||||
|
boundaries used for the actual cluster rollout.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- `deploy/railiance/platform/` is the `railiance-platform` handoff for the
|
||||||
|
`state-hub-db` CloudNativePG cluster, database bootstrap credential, and
|
||||||
|
database NetworkPolicies in the `databases` namespace.
|
||||||
|
- `deploy/railiance/apps/` is the `railiance-apps` handoff for the State Hub API
|
||||||
|
Helm chart, non-secret production values, and app namespace runtime Secret
|
||||||
|
template.
|
||||||
|
- Runtime secret values are not stored here. Replace placeholder passwords only
|
||||||
|
in an operator-controlled file, then encrypt or deliver through the approved
|
||||||
|
platform secret path.
|
||||||
|
|
||||||
|
## Image
|
||||||
|
|
||||||
|
The current image is pinned to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea.coulomb.social/coulomb/state-hub:b536741
|
||||||
|
```
|
||||||
|
|
||||||
|
railiance01 has already pulled this tag with `crictl`, and the image serves
|
||||||
|
`GET /state/health` against the local WSL database in smoke testing.
|
||||||
|
|
||||||
|
## Render And Dry-Run
|
||||||
|
|
||||||
|
Render the app chart without touching the cluster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make railiance-state-hub-render
|
||||||
|
```
|
||||||
|
|
||||||
|
Run client-side Kubernetes validation for the platform manifests, app Secret
|
||||||
|
template, and rendered chart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make railiance-state-hub-client-dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Run server-side dry-run against the configured representative cluster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECONFIG=~/.kube/config-hosteurope make railiance-state-hub-server-dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side dry-run requires the CNPG CRDs, namespace permissions, and dry-run
|
||||||
|
permission for resources in `databases` and `state-hub`.
|
||||||
|
Before the `state-hub` namespace exists, Kubernetes cannot server-dry-run namespaced app
|
||||||
|
objects into that namespace because dry-run Namespace creation is not persisted.
|
||||||
|
The Make target therefore server-validates the platform and Namespace manifests,
|
||||||
|
then falls back to client dry-run for namespaced app manifests with an explicit
|
||||||
|
notice.
|
||||||
|
|
||||||
|
## Promotion Notes
|
||||||
|
|
||||||
|
Platform promotion into `railiance-platform`:
|
||||||
|
|
||||||
|
- copy `platform/state-hub-db-credentials.sops.yaml.template` to a real SOPS
|
||||||
|
secret file with an operator-generated password;
|
||||||
|
- apply or GitOps-manage `platform/state-hub-db-cluster.yaml`;
|
||||||
|
- apply or GitOps-manage `platform/state-hub-db-networkpolicies.yaml`.
|
||||||
|
|
||||||
|
App promotion into `railiance-apps`:
|
||||||
|
|
||||||
|
- copy `apps/charts/state-hub/` to `charts/state-hub/`;
|
||||||
|
- copy `apps/helm/state-hub-values.yaml` to `helm/state-hub-values.yaml`;
|
||||||
|
- apply or GitOps-manage `apps/manifests/state-hub-namespace.yaml`;
|
||||||
|
- create `state-hub-env` in the `state-hub` namespace from the approved
|
||||||
|
secret-delivery path;
|
||||||
|
- deploy with Helm using the production values file, which sets
|
||||||
|
`namespace.create=false`, only after `state-hub-db` is healthy.
|
||||||
|
|
||||||
|
## Runtime Secret Contract
|
||||||
|
|
||||||
|
The app chart expects a Kubernetes Secret named `state-hub-env` in the
|
||||||
|
`state-hub` namespace with at least:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATABASE_URL=postgresql+asyncpg://state_hub:<url-encoded-password>@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional runtime settings such as `CORS_ORIGINS` can live in the chart
|
||||||
|
ConfigMap. The default chart keeps public ingress disabled; access should use
|
||||||
|
the existing private tunnel/ops-bridge path until a separate exposure decision
|
||||||
|
is recorded.
|
||||||
6
deploy/railiance/apps/charts/state-hub/Chart.yaml
Normal file
6
deploy/railiance/apps/charts/state-hub/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: state-hub
|
||||||
|
description: State Hub API service for private Railiance operation
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "b536741"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{{- define "statehub.fullname" -}}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||||
|
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "statehub.labels" -}}
|
||||||
|
app: {{ include "statehub.fullname" . }}
|
||||||
|
app.kubernetes.io/name: {{ include "statehub.fullname" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
app.kubernetes.io/part-of: railiance-apps
|
||||||
|
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||||
|
railiance.io/layer: s5-app
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "statehub.selectorLabels" -}}
|
||||||
|
app: {{ include "statehub.fullname" . }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "statehub.image" -}}
|
||||||
|
{{- if not .Values.image.tag -}}
|
||||||
|
{{- fail "image.tag is required - pin it in deploy/railiance/apps/helm/state-hub-values.yaml or pass --set image.tag=<sha>" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{- if .Values.config.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ .Values.config.name }}
|
||||||
|
labels: {{- include "statehub.labels" . | nindent 4 }}
|
||||||
|
data:
|
||||||
|
CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "statehub.fullname" . }}
|
||||||
|
labels: {{- include "statehub.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
selector:
|
||||||
|
matchLabels: {{- include "statehub.selectorLabels" . | nindent 6 }}
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {{- include "statehub.labels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets: {{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: state-hub
|
||||||
|
image: {{ include "statehub.image" . | quote }}
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
securityContext: {{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.service.targetPort }}
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
{{- if .Values.config.enabled }}
|
||||||
|
- configMapRef:
|
||||||
|
name: {{ .Values.config.name | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- secretRef:
|
||||||
|
name: {{ .Values.secret.name | quote }}
|
||||||
|
{{- if .Values.probes.enabled }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: {{ .Values.probes.path }}
|
||||||
|
port: {{ .Values.probes.port }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: {{ .Values.probes.path }}
|
||||||
|
port: {{ .Values.probes.port }}
|
||||||
|
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
resources: {{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector: {{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity: {{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations: {{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "statehub.fullname" . }}
|
||||||
|
labels: {{- include "statehub.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml .Values.ingress.annotations | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.ingress.host }}
|
||||||
|
secretName: {{ include "statehub.fullname" . }}-tls
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.ingress.host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "statehub.fullname" . }}
|
||||||
|
port:
|
||||||
|
number: {{ .Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{{- if .Values.namespace.create }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- toYaml .Values.namespace.labels | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "statehub.fullname" . }}
|
||||||
|
labels: {{- include "statehub.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: {{ .Values.service.targetPort }}
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector: {{- include "statehub.selectorLabels" . | nindent 4 }}
|
||||||
67
deploy/railiance/apps/charts/state-hub/values.yaml
Normal file
67
deploy/railiance/apps/charts/state-hub/values.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
image:
|
||||||
|
repository: gitea.coulomb.social/coulomb/state-hub
|
||||||
|
tag: ""
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
namespace:
|
||||||
|
create: true
|
||||||
|
labels:
|
||||||
|
railiance.io/postgres-client: state-hub-db
|
||||||
|
railiance.io/layer: s5-app
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
|
||||||
|
config:
|
||||||
|
enabled: true
|
||||||
|
name: state-hub-config
|
||||||
|
corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001"
|
||||||
|
|
||||||
|
secret:
|
||||||
|
name: state-hub-env
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: traefik
|
||||||
|
host: state-hub.coulomb.social
|
||||||
|
tls: true
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
path: /state/health
|
||||||
|
port: 8000
|
||||||
|
liveness:
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readiness:
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
podSecurityContext: {}
|
||||||
|
securityContext: {}
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
11
deploy/railiance/apps/helm/state-hub-values.yaml
Normal file
11
deploy/railiance/apps/helm/state-hub-values.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Production values for the State Hub Railiance chart handoff.
|
||||||
|
# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`.
|
||||||
|
|
||||||
|
namespace:
|
||||||
|
create: false
|
||||||
|
|
||||||
|
image:
|
||||||
|
tag: "b536741"
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Template for the State Hub runtime Secret in the state-hub namespace.
|
||||||
|
# DO NOT commit this file with real credentials.
|
||||||
|
# Encrypt with: sops -e -i state-hub-env.sops.yaml
|
||||||
|
# Apply with: kubectl apply -f <(sops -d state-hub-env.sops.yaml)
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: state-hub-env
|
||||||
|
namespace: state-hub
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: state-hub
|
||||||
|
app.kubernetes.io/component: runtime-env
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
railiance.io/layer: s5-app
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://state_hub:REPLACE_WITH_URL_ENCODED_PASSWORD@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub
|
||||||
8
deploy/railiance/apps/manifests/state-hub-namespace.yaml
Normal file
8
deploy/railiance/apps/manifests/state-hub-namespace.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: state-hub
|
||||||
|
labels:
|
||||||
|
railiance.io/layer: s5-app
|
||||||
|
railiance.io/postgres-client: state-hub-db
|
||||||
28
deploy/railiance/platform/state-hub-db-cluster.yaml
Normal file
28
deploy/railiance/platform/state-hub-db-cluster.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
# Dedicated CNPG Cluster for State Hub episodic memory.
|
||||||
|
# Owned by railiance-platform (S3). Operator lives in cnpg-system.
|
||||||
|
#
|
||||||
|
# Pre-condition: state-hub-db-credentials Secret exists in databases namespace.
|
||||||
|
# Runtime app Secret is separate and lives in the state-hub namespace.
|
||||||
|
apiVersion: postgresql.cnpg.io/v1
|
||||||
|
kind: Cluster
|
||||||
|
metadata:
|
||||||
|
name: state-hub-db
|
||||||
|
namespace: databases
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: state-hub-db
|
||||||
|
app.kubernetes.io/component: database
|
||||||
|
app.kubernetes.io/managed-by: manual
|
||||||
|
railiance.io/layer: s3-platform
|
||||||
|
railiance.io/role: state-hub-database
|
||||||
|
spec:
|
||||||
|
instances: 1
|
||||||
|
imageName: ghcr.io/cloudnative-pg/postgresql:16
|
||||||
|
storage:
|
||||||
|
size: 10Gi
|
||||||
|
bootstrap:
|
||||||
|
initdb:
|
||||||
|
database: state_hub
|
||||||
|
owner: state_hub
|
||||||
|
secret:
|
||||||
|
name: state-hub-db-credentials
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user