generated from coulomb/repo-seed
Compare commits
82 Commits
04366c64bc
...
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 | |||
| eacfccdffd | |||
| e4126bc755 | |||
| 044141de48 | |||
| af2972a460 | |||
| 152a83907a | |||
| 70df013675 | |||
| 38bde6cf89 | |||
| 1569ee4499 | |||
| 55e36bdf2d | |||
| 8f17bc1f50 | |||
| e9e9168921 | |||
| 54b867192d | |||
| 2cad5da0ab | |||
| 8191a3e85d | |||
| 99a66765f3 | |||
| b14844351c | |||
| 51364aea59 | |||
| 8428a02f6c | |||
| 3b48ce52a3 | |||
| 0e9a4ea93b |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## Kaizen Agents
|
||||
|
||||
Specialized agent personas available on demand via the state-hub MCP.
|
||||
|
||||
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||
|
||||
Common agents:
|
||||
|
||||
| Agent | Category | When to use |
|
||||
|-------|----------|-------------|
|
||||
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||
| `project-management` | process | Track status, determine next steps |
|
||||
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||
|
||||
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Architecture
|
||||
|
||||
<!-- TODO: Describe the key design decisions and component structure.
|
||||
Key modules, data flows, external integrations, state machines, etc. -->
|
||||
|
||||
## Quick Reference
|
||||
|
||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Credential and access routing
|
||||
|
||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=state-hub` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||
| Authorization decision | flex-auth | No — route only |
|
||||
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## First Session Protocol
|
||||
|
||||
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||
The project is registered but work has not yet been structured.
|
||||
|
||||
**Step 1 — Read, don't write**
|
||||
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||
- Scan repo root: README, directory structure, existing code or docs
|
||||
|
||||
**Step 2 — Survey in-progress work**
|
||||
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||
|
||||
**Step 3 — Propose workstreams to Bernd**
|
||||
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||
roadmap phase. **Wait for approval before creating.**
|
||||
|
||||
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||
```
|
||||
workplans/STATE-WP-NNNN-<slug>.md ← write this first
|
||||
```
|
||||
Then register in the hub:
|
||||
```
|
||||
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
|
||||
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||
```
|
||||
|
||||
**Step 5 — Record the setup**
|
||||
```
|
||||
add_progress_event(
|
||||
summary="First session: structured infotech into N workstreams, M tasks",
|
||||
event_type="milestone",
|
||||
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||
detail={"workstreams": [...], "tasks_created": M}
|
||||
)
|
||||
```
|
||||
|
||||
<!-- Delete or archive this file once past first session -->
|
||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Repo boundary
|
||||
|
||||
This repo owns **State Hub** only. It does not own:
|
||||
|
||||
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||
- SSH key management → railiance-infra/
|
||||
- State hub code → state-hub/
|
||||
-->
|
||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
||||
**Purpose:** 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:** infotech
|
||||
**Repo slug:** state-hub
|
||||
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
||||
## Session Protocol
|
||||
|
||||
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||
MCP server name in `~/.claude.json`: `dev-hub`
|
||||
|
||||
**Step 1 — Orient**
|
||||
|
||||
Read the offline-safe brief first — it works without a live hub connection:
|
||||
```bash
|
||||
cat .custodian-brief.md
|
||||
```
|
||||
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||
```
|
||||
get_domain_summary("infotech")
|
||||
```
|
||||
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||
```
|
||||
If the hub is offline: `cd ~/state-hub && make api`
|
||||
|
||||
**Step 2 — Check inbox**
|
||||
With MCP tools:
|
||||
```
|
||||
get_messages(to_agent="state-hub", unread_only=True)
|
||||
```
|
||||
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||
requests before proceeding.
|
||||
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
-H "Content-Type: application/json" -d '{}'
|
||||
```
|
||||
|
||||
**Step 3 — Scan workplans**
|
||||
```bash
|
||||
ls workplans/
|
||||
```
|
||||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`wait`/`todo`/`progress` tasks.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
|
||||
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||
4. **Suggested next action** — highest-priority open item
|
||||
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||
|
||||
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||
|
||||
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||
|
||||
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||
|
||||
**Session close:**
|
||||
With MCP tools:
|
||||
```
|
||||
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
|
||||
```
|
||||
Without MCP tools:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||
```
|
||||
If workplan files were modified, ensure the local copy is up to date first:
|
||||
```bash
|
||||
git -C <repo_path> pull --ff-only
|
||||
cd ~/state-hub && make fix-consistency REPO=state-hub
|
||||
```
|
||||
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||
use the combined target which pulls before fixing:
|
||||
```bash
|
||||
cd ~/state-hub && make fix-consistency-remote REPO=state-hub
|
||||
```
|
||||
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||
until you pull — intentional to prevent clobbering remote progress.
|
||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## Stack
|
||||
|
||||
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||
- **Language:**
|
||||
- **Key deps:**
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
# TODO: Fill in the standard commands for this repo
|
||||
|
||||
# Install dependencies
|
||||
|
||||
# Run tests
|
||||
|
||||
# Lint / type check
|
||||
|
||||
# Build / package (if applicable)
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
File location: `workplans/STATE-WP-NNNN-<slug>.md`
|
||||
ID prefix: `STATE-WP-`
|
||||
|
||||
Work items originate as files in this repo **before** being registered in the hub.
|
||||
|
||||
Canonical workplan/workstream frontmatter statuses are:
|
||||
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||
repo state, and `finished` when implementation is complete. `stalled` and
|
||||
`needs_review` are derived health labels, not stored statuses.
|
||||
|
||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||
prefix: `YYMMDD-STATE-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||
unchanged; the prefix is only for quick visual reference.
|
||||
|
||||
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||
multiple planned phases into a normal workplan.
|
||||
|
||||
Ecosystem todos from other agents arrive as `[repo:state-hub]` hub tasks —
|
||||
visible at session start. Pick one up by creating the workplan file, then registering
|
||||
the workstream.
|
||||
|
||||
Task blocks use this shape:
|
||||
|
||||
```task
|
||||
id: STATE-WP-NNNN-T01
|
||||
status: wait | todo | progress | done | cancel
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
```
|
||||
|
||||
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||
blocked work and `cancel` for stopped work.
|
||||
|
||||
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||
@@ -1,28 +1,12 @@
|
||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — state-hub
|
||||
|
||||
**Domain:** custodian
|
||||
**Last synced:** 2026-06-07 11:25 UTC
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-06-25 14:02 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### State Hub Agent Skill — front-load tool schemas + batched writes
|
||||
Progress: 0/3 done | workstream_id: `163b5646-163e-41a0-b60d-c402b578a4d1`
|
||||
|
||||
**Open tasks:**
|
||||
- · Skill scaffold: front-load common hub tool schemas `6f211ccc`
|
||||
- · Batched writes + bulk task-status sync op `2138505a`
|
||||
- · Validate against friction baseline + coordinate back to helix_forge `cf8d41f8`
|
||||
|
||||
### Overview Workstream Stage Counts
|
||||
Progress: 4/6 done | workstream_id: `43fb4c68-8ff8-4dc0-96f2-3c7976c16e9c`
|
||||
|
||||
**Open tasks:**
|
||||
- ! T06 — Verification `8513290e`
|
||||
*(wait: Browser click-through pending: the Codex in-app browser bridge failed to start in this session with a Windows sandbox setup failure, and no local Playwright/Puppeteer fallback is installed.)*
|
||||
- ► T05 — Close the Suggestion Feedback Loop `5e673e25`
|
||||
|
||||
### State Hub Full ThreePhoenix HA Migration
|
||||
Progress: 0/8 done | workstream_id: `8d0c1b5d-44da-4b91-8357-e6526d3e0a85`
|
||||
|
||||
@@ -37,13 +21,9 @@ Progress: 0/8 done | workstream_id: `8d0c1b5d-44da-4b91-8357-e6526d3e0a85`
|
||||
- … and 1 more open tasks
|
||||
|
||||
### 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:**
|
||||
- ► 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`
|
||||
- · T08 — Stabilisation period (2 weeks minimum) `e06a59a0`
|
||||
- · T09 — Retire WSL2 instance `d75a2d49`
|
||||
@@ -52,6 +32,6 @@ Progress: 2/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("custodian")`
|
||||
`get_domain_summary("infotech")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,7 +2,9 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/rules/
|
||||
!.claude/rules/*.md
|
||||
|
||||
# Python runtime and caches
|
||||
.venv/
|
||||
|
||||
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.
|
||||
71
AGENTS.md
71
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.
|
||||
|
||||
**Domain:** custodian
|
||||
**Domain:** infotech
|
||||
**Repo slug:** state-hub
|
||||
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||
**Workplan prefix:** `STATE-WP-`
|
||||
@@ -27,8 +27,8 @@ there is no MCP server for Codex agents.
|
||||
# Offline brief — works without hub connection
|
||||
cat .custodian-brief.md
|
||||
|
||||
# Active workplans for this domain
|
||||
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||
# Active workstreams for this domain
|
||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||
| python3 -m json.tool
|
||||
|
||||
# Check inbox
|
||||
@@ -80,7 +80,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
## Session Protocol
|
||||
|
||||
**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
|
||||
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`
|
||||
@@ -101,6 +101,63 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
|
||||
---
|
||||
|
||||
## Credential and access routing
|
||||
|
||||
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||
|
||||
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||
other credential need belongs to another subsystem. **Do not** message
|
||||
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||
|
||||
### Lookup (do this first)
|
||||
|
||||
```bash
|
||||
warden route find "<describe your need>" --json
|
||||
warden route show <catalog-id> --json
|
||||
```
|
||||
|
||||
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||
|
||||
| Agent runtime | How to orient |
|
||||
| --- | --- |
|
||||
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=state-hub` is for coordination, not secret vending |
|
||||
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||
|
||||
### Quick routing table
|
||||
|
||||
| I need… | Owner | ops-warden executes? |
|
||||
| --- | --- | --- |
|
||||
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||
| Authorization decision | flex-auth | No — route only |
|
||||
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||
|
||||
### Anti-patterns (do not do these)
|
||||
|
||||
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||
|
||||
### Other capabilities (reuse-surface)
|
||||
|
||||
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||
get wrong.
|
||||
|
||||
**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)
|
||||
|
||||
Work items originate as files in this repo — not in the hub. The hub is a
|
||||
@@ -124,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||
id: STATE-WP-NNNN
|
||||
type: workplan
|
||||
title: "..."
|
||||
domain: custodian
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: codex
|
||||
@@ -135,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
|
||||
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||
derived health labels, not frontmatter statuses.
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
@.claude/rules/stack-and-commands.md
|
||||
@.claude/rules/architecture.md
|
||||
@.claude/rules/repo-boundary.md
|
||||
@.claude/rules/credential-routing.md
|
||||
@.claude/rules/agents.md
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -20,10 +20,10 @@ with open("pyproject.toml", "rb") as f:
|
||||
project = tomllib.load(f)["project"]
|
||||
|
||||
for dep in project["dependencies"]:
|
||||
# llm-connect is currently a local editable test integration in this repo.
|
||||
# The State Hub API/MCP runtime does not import it, and a container build
|
||||
# must not depend on /home/worsch existing inside the image.
|
||||
if dep == "llm-connect":
|
||||
# llm-connect is a local editable test integration and must not be pulled
|
||||
# into the production image. hub-core is runtime code, but it is installed
|
||||
# from the named Docker build context below because it is not published yet.
|
||||
if dep in {"llm-connect", "hub-core"}:
|
||||
continue
|
||||
print(dep)
|
||||
PY
|
||||
@@ -31,6 +31,11 @@ PY
|
||||
RUN uv venv /app/.venv \
|
||||
&& 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 api/ ./api/
|
||||
COPY flows/ ./flows/
|
||||
|
||||
151
Makefile
151
Makefile
@@ -1,7 +1,19 @@
|
||||
.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
|
||||
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.
|
||||
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)
|
||||
|
||||
start:
|
||||
@echo "# run in different terminals"
|
||||
@@ -12,7 +24,7 @@ start:
|
||||
@echo "make bridges # Set up ssh bridges for cross machines access"
|
||||
|
||||
install:
|
||||
uv sync
|
||||
$(UV) sync
|
||||
|
||||
dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json
|
||||
cd dashboard && npm ci
|
||||
@@ -39,17 +51,17 @@ db-tools:
|
||||
$(COMPOSE) --profile tools up -d
|
||||
|
||||
migrate:
|
||||
uv run alembic upgrade head
|
||||
$(UV) run alembic upgrade head
|
||||
|
||||
seed:
|
||||
uv run python scripts/seed.py
|
||||
$(UV) run python scripts/seed.py
|
||||
|
||||
## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code.
|
||||
## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001).
|
||||
## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
|
||||
mcp-http:
|
||||
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
|
||||
MCP_TRANSPORT=sse MCP_PORT=8001 uv run python mcp_server/server.py
|
||||
MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py
|
||||
|
||||
dashboard:
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
@@ -59,11 +71,58 @@ dashboard:
|
||||
check:
|
||||
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-python:
|
||||
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
|
||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||
@@ -100,7 +159,7 @@ api: db
|
||||
done
|
||||
$(MAKE) migrate
|
||||
@fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true
|
||||
uv run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000
|
||||
$(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000
|
||||
|
||||
## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance
|
||||
register-project:
|
||||
@@ -168,7 +227,7 @@ list-repos:
|
||||
## Tip: run capture-tools first for repos with system-level tool dependencies.
|
||||
ingest-sbom:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_sbom.py --repo "$(REPO)" \
|
||||
$(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \
|
||||
$(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
@@ -179,12 +238,12 @@ ingest-sbom:
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-capabilities:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \
|
||||
$(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-capabilities-all:
|
||||
uv run python scripts/ingest_capabilities.py --all \
|
||||
$(UV) run python scripts/ingest_capabilities.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check Repository Definition of Integrated (DoI) criteria for a repo.
|
||||
@@ -193,10 +252,10 @@ ingest-capabilities-all:
|
||||
## Add JSON=1 for machine-readable output.
|
||||
check-doi:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json)
|
||||
$(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json)
|
||||
|
||||
check-doi-all:
|
||||
uv run python scripts/check_doi.py --all $(if $(JSON),--json)
|
||||
$(UV) run python scripts/check_doi.py --all $(if $(JSON),--json)
|
||||
|
||||
## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog.
|
||||
## Usage: make ingest-tpsc REPO=llm-connect
|
||||
@@ -204,11 +263,11 @@ check-doi-all:
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-tpsc:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_tpsc.py --repo "$(REPO)" \
|
||||
$(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-tpsc-all:
|
||||
uv run python scripts/ingest_tpsc.py --all \
|
||||
$(UV) run python scripts/ingest_tpsc.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
|
||||
@@ -216,33 +275,56 @@ ingest-tpsc-all:
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
capture-tools:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/capture_sbom_tools.py --repo "$(REPO)" \
|
||||
$(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian]
|
||||
validate-adr:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
|
||||
uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
|
||||
$(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
|
||||
|
||||
## Consistency exit contract:
|
||||
## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures.
|
||||
## - Agent/operator Make wrappers below normalize warning-only 2 to shell success
|
||||
## while preserving visible WARN output and keeping real failures non-zero.
|
||||
## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures
|
||||
## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures
|
||||
check-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1)
|
||||
uv run python scripts/consistency_check.py --repo "$(REPO)" \
|
||||
$(UV) run python scripts/consistency_check.py --repo "$(REPO)" \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures
|
||||
## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures
|
||||
fix-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1)
|
||||
uv run python scripts/consistency_check.py --repo "$(REPO)" --fix \
|
||||
$(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
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.
|
||||
## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1]
|
||||
token-reconcile:
|
||||
@@ -258,7 +340,7 @@ token-reconcile:
|
||||
## make fix-consistency-remote — smart pull+fix all repos that need it
|
||||
## make fix-consistency-remote REPO=slug — pull+fix one repo
|
||||
fix-consistency-remote:
|
||||
uv run python scripts/consistency_check.py \
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
$(if $(REPO),--repo "$(REPO)",--all) \
|
||||
--remote \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
@@ -268,14 +350,14 @@ fix-consistency-remote:
|
||||
## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo]
|
||||
## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag).
|
||||
check-consistency-here:
|
||||
uv run python scripts/consistency_check.py \
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo]
|
||||
fix-consistency-here:
|
||||
uv run python scripts/consistency_check.py \
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
--fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
@@ -283,19 +365,19 @@ fix-consistency-here:
|
||||
|
||||
## Check all registered repos for ADR-001 consistency
|
||||
check-consistency-all:
|
||||
uv run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
$(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix all registered repos
|
||||
fix-consistency-all:
|
||||
uv run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
$(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Cancel open tasks belonging to completed/archived workstreams.
|
||||
## Safe to run at any time; also suitable for a daily cron job.
|
||||
## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale
|
||||
cleanup-stale:
|
||||
uv run python scripts/cleanup_stale_tasks.py
|
||||
$(UV) run python scripts/cleanup_stale_tasks.py
|
||||
|
||||
## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx
|
||||
install-hooks:
|
||||
@@ -314,7 +396,22 @@ remove-hooks:
|
||||
## Compare Gitea coulomb org repos against state-hub registered repos
|
||||
## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN=<token>
|
||||
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:
|
||||
$(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-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
||||
| `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 dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
||||
| `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
|
||||
|
||||
Five tables in dependency order:
|
||||
Repo-anchored coordination spine (STATE-WP-0065):
|
||||
|
||||
```
|
||||
topics
|
||||
└── workstreams
|
||||
└── tasks (self-FK: parent_task_id)
|
||||
domains (14 market domains: infotech, financials, communication, …)
|
||||
managed_repos (classification: category, domain, capability_tags, business_stake, …)
|
||||
└── workplans (repo_id required; topic_id optional legacy tag)
|
||||
└── tasks
|
||||
└── progress_events
|
||||
decisions (FK: topic_id, workstream_id — at least one required)
|
||||
└── progress_events
|
||||
topics (optional cross-repo tag; domain_id → market domain)
|
||||
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` |
|
||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||
| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||
| `decision_type` | `made` · `pending` |
|
||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
||||
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
|
||||
| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
|
||||
| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
|
||||
|
||||
### 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
|
||||
|
||||
| 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`
|
||||
|
||||
**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
|
||||
|
||||
State Hub is the local-first coordination service for Custodian workstreams,
|
||||
tasks, decisions, progress events, repo metadata, MCP tooling, and dashboard
|
||||
telemetry.
|
||||
State Hub is the local-first coordination service for repo-anchored workplans,
|
||||
tasks, decisions, progress events, repo classification and metadata, MCP
|
||||
tooling, and dashboard telemetry.
|
||||
|
||||
## In Scope
|
||||
|
||||
@@ -12,7 +12,8 @@ telemetry.
|
||||
- PostgreSQL schema and Alembic migrations
|
||||
- FastMCP server and tool reference
|
||||
- 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
|
||||
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
|
||||
- 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.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 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 interface_changes
|
||||
from api.routers import flows
|
||||
from api.routers import recently_on_scope
|
||||
from api.routers import consistency_sweep
|
||||
from api.routers import reconciliation
|
||||
from api.routers import execution
|
||||
from api.routers import fabric
|
||||
@@ -90,18 +92,20 @@ _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()]
|
||||
|
||||
app.add_middleware(WriteIdempotencyMiddleware)
|
||||
app.add_middleware(ETagMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
||||
allow_headers=["Content-Type", "If-None-Match"],
|
||||
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache"],
|
||||
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", "X-StateHub-Idempotency-Replay"],
|
||||
)
|
||||
|
||||
app.include_router(domains.router)
|
||||
app.include_router(recently_on_scope.hourly_router)
|
||||
app.include_router(recently_on_scope.router)
|
||||
app.include_router(consistency_sweep.router)
|
||||
app.include_router(repos.router)
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
@@ -120,6 +124,7 @@ app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(token_events.router)
|
||||
app.include_router(interface_changes.router)
|
||||
app.include_router(flows.router)
|
||||
@@ -133,4 +138,4 @@ app.include_router(policy.router)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
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.managed_repo import ManagedRepo
|
||||
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_dependency import WorkstreamDependency
|
||||
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_request import CapabilityRequest
|
||||
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.token_event import TokenEvent
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
||||
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
||||
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -32,6 +42,8 @@ __all__ = [
|
||||
"Topic", "TopicStatus",
|
||||
"ManagedRepo",
|
||||
"RepoGoal", "RepoGoalStatus",
|
||||
"Workplan",
|
||||
"WorkplanDependency",
|
||||
"Workstream",
|
||||
"WorkstreamDependency",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
@@ -46,10 +58,13 @@ __all__ = [
|
||||
"CapabilityCatalog",
|
||||
"CapabilityRequest",
|
||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||
"ServiceCatalog", "ServiceThirdParty", "ServiceFirstParty",
|
||||
"ServiceCloud", "ServiceSelfHosted",
|
||||
"DOICache",
|
||||
"TokenEvent",
|
||||
"InterfaceChange",
|
||||
"WorkplanLaunchRequest",
|
||||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||
]
|
||||
"WriteIdempotencyKey",
|
||||
]
|
||||
@@ -31,9 +31,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
@@ -45,9 +45,9 @@ class CapabilityRequest(Base, TimestampMixin):
|
||||
nullable=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),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||
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(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -25,8 +25,8 @@ class Decision(Base, TimestampMixin):
|
||||
__tablename__ = "decisions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workstream",
|
||||
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workplan",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,8 +36,8 @@ class Decision(Base, TimestampMixin):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
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
|
||||
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
|
||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||
)
|
||||
|
||||
@@ -44,13 +44,13 @@ class ExtensionPoint(Base, TimestampMixin):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", 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
|
||||
def domain_slug(self) -> str:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
@@ -36,6 +36,15 @@ class ManagedRepo(Base, TimestampMixin):
|
||||
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", back_populates="repos", lazy="selectin"
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@ class ProgressEvent(Base):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
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
|
||||
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
|
||||
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
|
||||
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
||||
)
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="repo_goal", lazy="selectin"
|
||||
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||
"Workplan", back_populates="repo_goal", lazy="selectin"
|
||||
)
|
||||
|
||||
@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(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
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
|
||||
)
|
||||
|
||||
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(
|
||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||
)
|
||||
|
||||
@@ -76,13 +76,13 @@ class TechnicalDebt(Base, TimestampMixin):
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", 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(
|
||||
"TDNote", back_populates="td", lazy="selectin",
|
||||
order_by="TDNote.created_at",
|
||||
|
||||
@@ -27,8 +27,8 @@ class TokenEvent(Base):
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), 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
|
||||
@@ -75,5 +75,5 @@ class TokenEvent(Base):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -36,8 +36,8 @@ class Topic(Base, TimestampMixin):
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="topics", lazy="selectin"
|
||||
)
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="topic", lazy="selectin"
|
||||
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||
"Workplan", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"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(
|
||||
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="CASCADE"),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
@@ -36,4 +36,4 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
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
|
||||
from datetime import date, datetime
|
||||
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
Workstream = Workplan
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
__all__ = ["Workstream", "Workplan"]
|
||||
@@ -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
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
WorkstreamDependency = WorkplanDependency
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
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
|
||||
__all__ = ["WorkstreamDependency", "WorkplanDependency"]
|
||||
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 uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,9 +14,6 @@ from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.schemas.capability_request import (
|
||||
CatalogCreate,
|
||||
CatalogPatch,
|
||||
CatalogRead,
|
||||
CapabilityRequestAccept,
|
||||
CapabilityRequestCreate,
|
||||
CapabilityRequestDispute,
|
||||
@@ -26,127 +22,61 @@ from api.schemas.capability_request import (
|
||||
CapabilityRequestReroute,
|
||||
CapabilityRequestStatusPatch,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["capability-requests"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_catalog_entry(
|
||||
body: CatalogCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
domain = await _resolve_domain(body.domain, session)
|
||||
|
||||
repo_id = None
|
||||
if body.repo_slug:
|
||||
repo = await _resolve_repo(body.repo_slug, session)
|
||||
repo_id = repo.id
|
||||
|
||||
entry = CapabilityCatalog(
|
||||
domain_id=domain.id,
|
||||
repo_id=repo_id,
|
||||
capability_type=body.capability_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
keywords=body.keywords,
|
||||
)
|
||||
session.add(entry)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Catalog entry '{body.title}' for type '{body.capability_type}' already exists in domain '{body.domain}'",
|
||||
)
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
|
||||
async def patch_catalog_entry(
|
||||
entry_id: uuid.UUID,
|
||||
body: CatalogPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
entry = await session.get(CapabilityCatalog, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||
|
||||
if body.repo_slug is not None:
|
||||
repo = await _resolve_repo(body.repo_slug, session)
|
||||
entry.repo_id = repo.id
|
||||
if body.description is not None:
|
||||
entry.description = body.description
|
||||
if body.keywords is not None:
|
||||
entry.keywords = body.keywords
|
||||
if body.status is not None:
|
||||
entry.status = body.status
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
||||
async def list_catalog(
|
||||
domain: str | None = Query(None),
|
||||
capability_type: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityCatalog]:
|
||||
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
||||
if domain:
|
||||
d = await _resolve_domain(domain, session)
|
||||
q = q.where(CapabilityCatalog.domain_id == d.id)
|
||||
if capability_type:
|
||||
q = q.where(CapabilityCatalog.capability_type == capability_type)
|
||||
if status_filter and status_filter != "all":
|
||||
q = q.where(CapabilityCatalog.status == status_filter)
|
||||
elif not status_filter:
|
||||
q = q.where(CapabilityCatalog.status == "active")
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
from hub_core.routers.capabilities import (
|
||||
create_capability_catalog_router,
|
||||
create_capability_request_read_router,
|
||||
create_capability_request_write_router,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Request endpoints
|
||||
# Write-router callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_request(
|
||||
async def _route_capability(
|
||||
session: AsyncSession,
|
||||
body: CapabilityRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req_domain = await _resolve_domain(body.requesting_domain, session)
|
||||
|
||||
# Route to provider
|
||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
|
||||
session, body.capability_type, body.title, body.description or ""
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
|
||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
|
||||
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,
|
||||
description=body.description,
|
||||
capability_type=body.capability_type,
|
||||
priority=body.priority,
|
||||
requesting_domain_id=req_domain.id,
|
||||
requesting_domain_id=requesting_domain.id,
|
||||
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,
|
||||
fulfilling_domain_id=fulfilling_domain_id,
|
||||
catalog_entry_id=catalog_entry_id,
|
||||
routing_note=routing_note,
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # get req.id before creating notification
|
||||
|
||||
# Auto-notify
|
||||
if fulfilling_domain_id:
|
||||
ful_domain = await session.get(Domain, fulfilling_domain_id)
|
||||
|
||||
async def _notify_on_create(
|
||||
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"
|
||||
else:
|
||||
to_agent = "broadcast"
|
||||
@@ -165,59 +95,16 @@ async def create_request(
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
|
||||
|
||||
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
|
||||
async def list_requests(
|
||||
domain: str | None = Query(None, description="Filter by requesting OR fulfilling domain slug"),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
capability_type: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityRequest]:
|
||||
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
|
||||
if domain:
|
||||
d = await _resolve_domain(domain, session)
|
||||
q = q.where(
|
||||
(CapabilityRequest.requesting_domain_id == d.id)
|
||||
| (CapabilityRequest.fulfilling_domain_id == d.id)
|
||||
)
|
||||
if status_filter:
|
||||
q = q.where(CapabilityRequest.status == status_filter)
|
||||
if capability_type:
|
||||
q = q.where(CapabilityRequest.capability_type == capability_type)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||
async def get_request(
|
||||
request_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
return await _get_request_or_404(request_id, session)
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
||||
async def accept_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _notify_on_accept(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestAccept,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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)
|
||||
|
||||
) -> None:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.fulfilling_agent,
|
||||
@@ -226,30 +113,14 @@ async def accept_request(
|
||||
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
||||
async def patch_request_status(
|
||||
request_id: uuid.UUID,
|
||||
async def _on_status_change(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestStatusPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
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
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if body.status == "completed":
|
||||
req.completed_at = now
|
||||
# Auto-unblock the blocking task
|
||||
if req.blocking_task_id:
|
||||
task = await session.get(Task, req.blocking_task_id)
|
||||
if task and task.status == "wait":
|
||||
@@ -297,23 +168,12 @@ async def patch_request_status(
|
||||
body=f"Work on capability **{req.title}** is now in progress.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||
async def patch_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _apply_capability_patch(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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)
|
||||
|
||||
) -> bool:
|
||||
corrections: list[str] = []
|
||||
|
||||
if body.catalog_entry_id is not None:
|
||||
@@ -322,8 +182,6 @@ async def patch_request(
|
||||
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
|
||||
# Re-derive fulfilling domain from catalog entry
|
||||
old_domain_id = req.fulfilling_domain_id
|
||||
req.fulfilling_domain_id = entry.domain_id
|
||||
corrections.append(
|
||||
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
||||
@@ -338,49 +196,30 @@ async def patch_request(
|
||||
req.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:
|
||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
|
||||
if body.fulfilling_workplan_id is not None:
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
|
||||
|
||||
if not corrections:
|
||||
return req # no-op
|
||||
return False
|
||||
|
||||
correction_note = "hub correction: " + "; ".join(corrections)
|
||||
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispute endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
|
||||
async def dispute_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _notify_on_dispute(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestDispute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""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
|
||||
|
||||
now: datetime,
|
||||
) -> None:
|
||||
dispute_entry = (
|
||||
f"disputed by {body.disputed_by}: {body.reason}"
|
||||
+ (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
|
||||
|
||||
# Notify custodian
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.disputed_by,
|
||||
@@ -394,7 +233,6 @@ async def dispute_request(
|
||||
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
|
||||
),
|
||||
)
|
||||
# Notify current fulfilling domain
|
||||
if req.fulfilling_domain_slug:
|
||||
_add_notification(
|
||||
session,
|
||||
@@ -409,52 +247,13 @@ async def dispute_request(
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
|
||||
async def reroute_request(
|
||||
request_id: uuid.UUID,
|
||||
async def _notify_on_reroute(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestReroute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""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
|
||||
new_domain_slug: str,
|
||||
) -> None:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
@@ -465,7 +264,6 @@ async def reroute_request(
|
||||
f"**Note:** {body.note}"
|
||||
),
|
||||
)
|
||||
# Notify new fulfilling domain
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
@@ -480,24 +278,20 @@ async def reroute_request(
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing algorithm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _route_capability(
|
||||
session: AsyncSession, capability_type: str, title: str, description: str
|
||||
async def _route_capability_match(
|
||||
session: AsyncSession,
|
||||
capability_type: str,
|
||||
title: str,
|
||||
description: str,
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
||||
"""Find the best-matching catalog entry for a capability request.
|
||||
|
||||
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(
|
||||
CapabilityCatalog.capability_type == capability_type,
|
||||
@@ -509,20 +303,19 @@ async def _route_capability(
|
||||
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
||||
|
||||
if len(entries) == 1:
|
||||
e = entries[0]
|
||||
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
|
||||
entry = entries[0]
|
||||
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()
|
||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||
for entry in entries:
|
||||
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
||||
score = sum(
|
||||
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.sort(key=lambda x: -x[0])
|
||||
scored.sort(key=lambda item: -item[0])
|
||||
|
||||
best_score, best = scored[0]
|
||||
if best_score == 0:
|
||||
@@ -553,7 +346,6 @@ def _add_notification(
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Create an AgentMessage notification in the current session (no commit)."""
|
||||
msg = AgentMessage(
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
@@ -563,29 +355,6 @@ def _add_notification(
|
||||
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 _resolve_repo(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
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:
|
||||
can_reach, failures, flow_result = evaluate_transition(
|
||||
"capability_request",
|
||||
@@ -605,3 +374,44 @@ def _check_transition(current: str, target: str) -> None:
|
||||
"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,
|
||||
body_path=body.body_path,
|
||||
related_topic_id=body.related_topic_id,
|
||||
related_workstream_id=body.related_workstream_id,
|
||||
related_workplan_id=body.related_workplan_id,
|
||||
notes=body.notes,
|
||||
status=ContributionStatus.draft,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
|
||||
@router.get("/", response_model=list[DecisionRead])
|
||||
async def list_decisions(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: DecisionStatus | None = None,
|
||||
decision_type: DecisionType | None = None,
|
||||
@@ -48,8 +49,9 @@ async def list_decisions(
|
||||
q = select(Decision)
|
||||
if topic_id:
|
||||
q = q.where(Decision.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(Decision.workstream_id == workstream_id)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Decision.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Decision.status == status)
|
||||
if decision_type:
|
||||
@@ -139,7 +141,7 @@ async def resolve_decision_action(
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=decision.topic_id,
|
||||
workstream_id=decision.workstream_id,
|
||||
workplan_id=decision.workplan_id,
|
||||
decision_id=decision.id,
|
||||
event_type="decision_resolved",
|
||||
summary=f"Decision resolved: {decision.title}",
|
||||
@@ -159,7 +161,7 @@ async def resolve_decision_action(
|
||||
"decision_id": str(decision.id),
|
||||
"title": decision.title,
|
||||
"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,
|
||||
"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.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.domain import (
|
||||
DomainCreate,
|
||||
DomainDetail,
|
||||
@@ -32,9 +32,9 @@ async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainD
|
||||
workstream_count = 0
|
||||
if topic_ids:
|
||||
workstream_count_row = await session.execute(
|
||||
select(func.count()).select_from(Workstream)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.where(Workstream.status == "active")
|
||||
select(func.count()).select_from(Workplan)
|
||||
.where(Workplan.topic_id.in_(topic_ids))
|
||||
.where(Workplan.status == "active")
|
||||
)
|
||||
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.models.task import Task, TaskStatus
|
||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.execution import (
|
||||
ExecutionIntentRead,
|
||||
ExecutionIntentUpdate,
|
||||
@@ -25,10 +25,10 @@ from api.services.execution_queue import (
|
||||
STATE_HUB_RESPONSIBILITIES,
|
||||
execution_state_for_launch,
|
||||
queue_sort_key,
|
||||
workstream_blockers,
|
||||
workplan_blockers,
|
||||
)
|
||||
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"])
|
||||
|
||||
@@ -50,7 +50,7 @@ async def _update_execution_intent(
|
||||
body: ExecutionIntentUpdate,
|
||||
session: AsyncSession,
|
||||
) -> ExecutionIntentRead:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
ws = await session.get(Workplan, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
@@ -94,22 +94,22 @@ async def workplan_stack(
|
||||
include_blocked: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkplanQueueItem]:
|
||||
result = await session.execute(select(Workstream))
|
||||
result = await session.execute(select(Workplan))
|
||||
workstreams = [
|
||||
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_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]] = {}
|
||||
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
for dep in dep_result.scalars().all():
|
||||
if dep.to_workstream_id is not None:
|
||||
ws_deps.setdefault(dep.from_workstream_id, []).append(dep.to_workstream_id)
|
||||
if dep.to_workplan_id is not None:
|
||||
ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
|
||||
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_status: dict[uuid.UUID, str] = {}
|
||||
@@ -121,9 +121,9 @@ async def workplan_stack(
|
||||
for ws in workstreams:
|
||||
if not include_manual and ws.execution_state == "manual":
|
||||
continue
|
||||
lifecycle_status = normalize_workstream_status(ws.status)
|
||||
lifecycle_status = normalize_workplan_status(ws.status)
|
||||
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
|
||||
]
|
||||
blocked_tasks = [
|
||||
@@ -135,7 +135,7 @@ async def workplan_stack(
|
||||
continue
|
||||
sort_key = queue_sort_key(ws, eligible=eligible)
|
||||
items.append(WorkplanQueueItem(
|
||||
workstream_id=ws.id,
|
||||
workplan_id=ws.id,
|
||||
slug=ws.slug,
|
||||
title=ws.title,
|
||||
status=lifecycle_status,
|
||||
@@ -149,7 +149,7 @@ async def workplan_stack(
|
||||
execution_group=ws.execution_group,
|
||||
scheduled_for=ws.scheduled_for,
|
||||
eligible=eligible,
|
||||
blocked_by_workstream_ids=blocked_ws,
|
||||
blocked_by_workplan_ids=blocked_ws,
|
||||
blocked_by_task_ids=blocked_tasks,
|
||||
sort_key=sort_key,
|
||||
))
|
||||
@@ -165,12 +165,12 @@ async def create_launch_request(
|
||||
body: LaunchRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkplanLaunchRequest:
|
||||
ws = await session.get(Workstream, body.workstream_id)
|
||||
ws = await session.get(Workplan, body.workplan_id)
|
||||
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(
|
||||
workstream_id=ws.id,
|
||||
workplan_id=ws.id,
|
||||
requested_by=body.requested_by,
|
||||
requested_actor=body.requested_actor,
|
||||
launch_mode=body.launch_mode,
|
||||
@@ -199,16 +199,16 @@ async def list_launch_requests(
|
||||
) -> list[WorkplanLaunchRequest]:
|
||||
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
||||
if workstream_id:
|
||||
q = q.where(WorkplanLaunchRequest.workstream_id == workstream_id)
|
||||
q = q.where(WorkplanLaunchRequest.workplan_id == workstream_id)
|
||||
if request_status:
|
||||
q = q.where(WorkplanLaunchRequest.status == request_status)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _intent_read(ws: Workstream) -> ExecutionIntentRead:
|
||||
def _intent_read(ws: Workplan) -> ExecutionIntentRead:
|
||||
return ExecutionIntentRead(
|
||||
workstream_id=ws.id,
|
||||
workplan_id=ws.id,
|
||||
execution_state=ws.execution_state,
|
||||
launch_mode=ws.launch_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.contribution import Contribution
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.services.lifecycle import transition_task_status, transition_workstream_status
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.services.lifecycle import transition_task_status, transition_workplan_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
@@ -94,9 +94,9 @@ async def advance_workstation(
|
||||
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
if entity_type == "workstream":
|
||||
transition_workstream_status(entity, target_workstation)
|
||||
transition_workplan_status(entity, target_workstation)
|
||||
elif entity_type == "task":
|
||||
parent = await session.get(Workstream, entity.workstream_id)
|
||||
parent = await session.get(Workplan, entity.workplan_id)
|
||||
transition_task_status(
|
||||
entity,
|
||||
target_workstation,
|
||||
@@ -117,7 +117,7 @@ async def _flow_object(
|
||||
) -> dict[str, Any]:
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
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] = {
|
||||
"id": str(entity.id),
|
||||
"status": current_status,
|
||||
@@ -127,21 +127,21 @@ async def _flow_object(
|
||||
|
||||
if entity_type == "workstream":
|
||||
tasks = list((await session.execute(
|
||||
select(Task).where(Task.workstream_id == entity_id)
|
||||
select(Task).where(Task.workplan_id == entity_id)
|
||||
)).scalars().all())
|
||||
deps = list((await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
WorkstreamDependency.from_workstream_id == entity_id
|
||||
select(WorkplanDependency).where(
|
||||
WorkplanDependency.from_workplan_id == entity_id
|
||||
)
|
||||
)).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]] = []
|
||||
if dependency_ids:
|
||||
dep_ws = list((await session.execute(
|
||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
||||
select(Workplan).where(Workplan.id.in_(dependency_ids))
|
||||
)).scalars().all())
|
||||
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
|
||||
]
|
||||
obj.update({
|
||||
@@ -163,7 +163,7 @@ async def _entity(
|
||||
session: AsyncSession,
|
||||
):
|
||||
model_by_type = {
|
||||
"workstream": Workstream,
|
||||
"workstream": Workplan,
|
||||
"task": Task,
|
||||
"contribution": Contribution,
|
||||
"capability_request": CapabilityRequest,
|
||||
|
||||
@@ -9,23 +9,23 @@ from api.models.agent_message import AgentMessage
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
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.services.lifecycle import (
|
||||
should_activate_parent_for_task_start,
|
||||
status_value,
|
||||
transition_task_status,
|
||||
transition_workstream_status,
|
||||
transition_workplan_status,
|
||||
)
|
||||
from api.task_status import TERMINAL_TASK_STATUSES
|
||||
from api.services.reconciliation import (
|
||||
ReconciliationClass,
|
||||
StateChangeClassification,
|
||||
classify_task_status_change,
|
||||
classify_workstream_status_change,
|
||||
classify_workplan_status_change,
|
||||
)
|
||||
from api.services.workplan_files import (
|
||||
find_workplan_for_workstream,
|
||||
find_workplan_for_workplan,
|
||||
patch_task_status,
|
||||
patch_workplan_status,
|
||||
resolve_repo_path,
|
||||
@@ -33,7 +33,7 @@ from api.services.workplan_files import (
|
||||
task_block_linked,
|
||||
workplan_status,
|
||||
)
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
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:
|
||||
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()]
|
||||
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),
|
||||
) -> StateChangeResponse:
|
||||
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:
|
||||
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_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_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
@@ -122,9 +122,9 @@ async def classify_state_change(
|
||||
if body.tasks_terminal is not None
|
||||
else await _workstream_tasks_terminal(session, ws.id)
|
||||
)
|
||||
current_status = normalize_workstream_status(ws.status)
|
||||
target_status = normalize_workstream_status(body.target_status)
|
||||
classification = classify_workstream_status_change(
|
||||
current_status = normalize_workplan_status(ws.status)
|
||||
target_status = normalize_workplan_status(body.target_status)
|
||||
classification = classify_workplan_status_change(
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
@@ -136,7 +136,7 @@ async def classify_state_change(
|
||||
conflict = False
|
||||
if body.apply:
|
||||
expected_status = (
|
||||
normalize_workstream_status(body.expected_current_status)
|
||||
normalize_workplan_status(body.expected_current_status)
|
||||
if body.expected_current_status is not None
|
||||
else None
|
||||
)
|
||||
@@ -153,7 +153,7 @@ async def classify_state_change(
|
||||
)
|
||||
conflict = True
|
||||
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:
|
||||
classification = _conflict(
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
classification = _conflict(
|
||||
f"workplan file write failed: {exc}",
|
||||
@@ -178,7 +178,7 @@ async def classify_state_change(
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
transition_workstream_status(ws, target_status)
|
||||
transition_workplan_status(ws, target_status)
|
||||
await session.commit()
|
||||
write_result = "applied"
|
||||
|
||||
@@ -221,10 +221,10 @@ async def classify_state_change(
|
||||
if task is None:
|
||||
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_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_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
@@ -291,7 +291,7 @@ async def classify_state_change(
|
||||
parent_will_activate = should_activate_parent_for_task_start(
|
||||
previous_task_status=current_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:
|
||||
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))
|
||||
if parent_will_activate:
|
||||
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 original_text is not None:
|
||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||
|
||||
@@ -10,9 +10,9 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import noload
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
@@ -30,11 +30,11 @@ from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
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.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
DispatchWorkplan,
|
||||
PendingInterfaceChange,
|
||||
RepoCreate,
|
||||
RepoDispatch,
|
||||
@@ -45,25 +45,94 @@ from api.schemas.managed_repo import (
|
||||
RepoScopeHealth,
|
||||
RepoUpdate,
|
||||
ScopeIssueDetail,
|
||||
classification_fields_set,
|
||||
validate_repo_classification_fields,
|
||||
)
|
||||
from hub_core.routers.repos import create_repos_router
|
||||
|
||||
router = APIRouter(prefix="/repos", tags=["repos"])
|
||||
|
||||
|
||||
async def _publish_repo_registered(repo: ManagedRepo, body: RepoCreate, domain: Domain) -> None:
|
||||
subject = "org.statehub.repo.registered"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"repo_id": str(repo.id),
|
||||
"repo_slug": repo.slug,
|
||||
"domain_slug": domain.slug,
|
||||
"remote_url": repo.remote_url,
|
||||
"local_path": repo.local_path,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
|
||||
def _core_repo_router(**route_flags) -> APIRouter:
|
||||
return create_repos_router(
|
||||
get_session,
|
||||
prefix="",
|
||||
domain_model=Domain,
|
||||
repo_model=ManagedRepo,
|
||||
repo_create_schema=RepoCreate,
|
||||
repo_update_schema=RepoUpdate,
|
||||
repo_read_schema=RepoRead,
|
||||
repo_path_register_schema=RepoPathRegister,
|
||||
list_noload_fields=("goals",),
|
||||
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,
|
||||
**route_flags,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
q = (
|
||||
select(ManagedRepo)
|
||||
.options(noload(ManagedRepo.goals))
|
||||
.order_by(ManagedRepo.name)
|
||||
)
|
||||
if domain:
|
||||
domain_row = await session.execute(select(Domain).where(Domain.slug == domain))
|
||||
domain_obj = domain_row.scalar_one_or_none()
|
||||
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(ManagedRepo.domain_id == domain_obj.id)
|
||||
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())
|
||||
|
||||
@@ -73,42 +142,43 @@ async def register_repo(
|
||||
body: RepoCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
domain_row = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
|
||||
domain_obj = domain_row.scalar_one_or_none()
|
||||
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)
|
||||
|
||||
subject = "org.statehub.repo.registered"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"repo_id": str(repo.id),
|
||||
"repo_slug": repo.slug,
|
||||
"domain_slug": body.domain_slug,
|
||||
"remote_url": repo.remote_url,
|
||||
"local_path": repo.local_path,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
await _publish_repo_registered(repo, body, domain_obj)
|
||||
return repo
|
||||
|
||||
|
||||
@@ -183,43 +253,6 @@ async def onboard_repo(body: RepoOnboardRequest) -> RepoOnboardResult:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-fingerprint", response_model=list[RepoRead])
|
||||
async def get_repo_by_fingerprint(
|
||||
hash: str,
|
||||
remote_url: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ManagedRepo]:
|
||||
"""Look up repos by git root-commit SHA-1 fingerprint.
|
||||
|
||||
The fingerprint is the output of ``git rev-list --max-parents=0 HEAD`` and
|
||||
is identical across every clone of the same repository. Repos that share
|
||||
git history (forks, monorepo splits) will have the same fingerprint.
|
||||
|
||||
Pass ``remote_url`` to narrow results to a specific remote — useful when
|
||||
multiple repos share the same ancestor commit.
|
||||
|
||||
Returns an empty list if no match is found.
|
||||
"""
|
||||
q = select(ManagedRepo).where(ManagedRepo.git_fingerprint == hash)
|
||||
if remote_url:
|
||||
q = q.where(ManagedRepo.remote_url == remote_url)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/by-remote", response_model=RepoRead)
|
||||
async def get_repo_by_remote_url(
|
||||
url: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
"""Look up a repo by its git remote URL (fallback; prefer /by-fingerprint)."""
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.remote_url == url))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"No repo with remote_url '{url}' found")
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/doi/summary", response_model=list[DoISummaryEntry])
|
||||
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
|
||||
"""Return DoI tier for all active repos, worst tier first.
|
||||
@@ -492,46 +525,44 @@ async def list_repo_scope_health(
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=RepoRead)
|
||||
async def get_repo(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
return await _get_repo_by_slug(slug, session)
|
||||
|
||||
|
||||
@router.patch("/{slug}", response_model=RepoRead)
|
||||
async def update_repo(
|
||||
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)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
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.post("/{slug}/paths", response_model=RepoRead)
|
||||
async def register_host_path(
|
||||
slug: str,
|
||||
body: RepoPathRegister,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
"""Register or update the local path for a specific host.
|
||||
|
||||
Merges {"host": path} into host_paths without overwriting other entries.
|
||||
Use this when a repo lives at a different absolute path on different machines.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
updated = dict(repo.host_paths or {})
|
||||
updated[body.host] = body.path
|
||||
repo.host_paths = updated
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
router.include_router(
|
||||
_core_repo_router(
|
||||
include_collection_routes=False,
|
||||
include_lookup_routes=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{slug}/archive", response_model=RepoRead)
|
||||
@@ -578,19 +609,19 @@ async def get_repo_dispatch(
|
||||
|
||||
# Active workstreams
|
||||
ws_result = await session.execute(
|
||||
select(Workstream)
|
||||
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
|
||||
.order_by(Workstream.created_at)
|
||||
select(Workplan)
|
||||
.where(Workplan.repo_id == repo.id, Workplan.status == "active")
|
||||
.order_by(Workplan.created_at)
|
||||
)
|
||||
workstreams = list(ws_result.scalars().all())
|
||||
|
||||
dispatch_workstreams: list[DispatchWorkstream] = []
|
||||
dispatch_workstreams: list[DispatchWorkplan] = []
|
||||
all_interventions: list[DispatchTask] = []
|
||||
|
||||
for ws in workstreams:
|
||||
task_result = await session.execute(
|
||||
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)
|
||||
)
|
||||
tasks = list(task_result.scalars().all())
|
||||
@@ -609,7 +640,7 @@ async def get_repo_dispatch(
|
||||
all_interventions.extend(interventions)
|
||||
|
||||
dispatch_workstreams.append(
|
||||
DispatchWorkstream(
|
||||
DispatchWorkplan(
|
||||
id=ws.id,
|
||||
title=ws.title,
|
||||
status=ws.status,
|
||||
@@ -652,7 +683,7 @@ async def get_repo_dispatch(
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
active_goal=active_goal,
|
||||
active_workstreams=dispatch_workstreams,
|
||||
active_workplans=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
pending_interface_changes=pending_changes,
|
||||
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.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.models.capability_request import CapabilityRequest
|
||||
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.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
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_dependency import WorkstreamDepStub
|
||||
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.workplan_status import (
|
||||
CLOSED_WORKSTREAM_STATUSES,
|
||||
OPEN_WORKSTREAM_STATUSES,
|
||||
normalize_workstream_status,
|
||||
CLOSED_WORKPLAN_STATUSES,
|
||||
OPEN_WORKPLAN_STATUSES,
|
||||
normalize_workplan_status,
|
||||
)
|
||||
from task_flow_engine import FlowEngine
|
||||
|
||||
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_AT: float = 0.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)
|
||||
async def get_summary(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
refresh: bool = False,
|
||||
) -> StateSummary:
|
||||
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
|
||||
no_cache = "no-cache" in request.headers.get("cache-control", "")
|
||||
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
|
||||
response.headers["X-StateHub-Cache"] = "hit"
|
||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
||||
return _SUMMARY_CACHE
|
||||
response.headers["X-StateHub-Cache"] = "miss"
|
||||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
|
||||
revision = await fetch_summary_revision(session)
|
||||
revision_token = revision.combined_fingerprint()
|
||||
force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "")
|
||||
|
||||
cache = get_summary_cache()
|
||||
cache_status, cached = cache.resolve(revision, force_refresh=force_refresh)
|
||||
|
||||
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.
|
||||
# AsyncSession does not support concurrent operations (no gather on same session).
|
||||
|
||||
@@ -82,7 +118,7 @@ async def get_summary(
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
@@ -96,16 +132,16 @@ async def get_summary(
|
||||
if topic_ids:
|
||||
topic_ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.topic_id,
|
||||
Workstream.id,
|
||||
Workstream.slug,
|
||||
Workstream.title,
|
||||
Workstream.status,
|
||||
Workstream.owner,
|
||||
Workstream.due_date,
|
||||
Workplan.topic_id,
|
||||
Workplan.id,
|
||||
Workplan.slug,
|
||||
Workplan.title,
|
||||
Workplan.status,
|
||||
Workplan.owner,
|
||||
Workplan.due_date,
|
||||
)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.order_by(Workstream.created_at)
|
||||
.where(Workplan.topic_id.in_(topic_ids))
|
||||
.order_by(Workplan.created_at)
|
||||
)
|
||||
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
||||
topic_workstreams.setdefault(topic_id, []).append({
|
||||
@@ -136,10 +172,10 @@ async def get_summary(
|
||||
recent = list(recent_rows.scalars().all())
|
||||
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
@@ -147,7 +183,7 @@ async def get_summary(
|
||||
task_per_ws: dict = {}
|
||||
task_statuses_per_ws: dict = {}
|
||||
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_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
|
||||
@@ -157,9 +193,9 @@ async def get_summary(
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -168,16 +204,16 @@ async def get_summary(
|
||||
dep_ws_ids = set()
|
||||
dep_task_ids = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
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():
|
||||
ws_lookup[w.id] = w
|
||||
@@ -189,7 +225,7 @@ async def get_summary(
|
||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
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:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id,
|
||||
@@ -230,9 +266,9 @@ async def get_summary(
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"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
|
||||
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)
|
||||
@@ -246,7 +282,7 @@ async def get_summary(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
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(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
@@ -370,11 +406,13 @@ async def get_summary(
|
||||
for w in open_ws
|
||||
],
|
||||
)
|
||||
_SUMMARY_CACHE = result
|
||||
_SUMMARY_CACHE_AT = time.monotonic()
|
||||
return result
|
||||
|
||||
|
||||
get_summary_cache().configure(build_state_summary)
|
||||
register_summary_cache_invalidation()
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview)
|
||||
async def get_overview(
|
||||
request: Request,
|
||||
@@ -407,7 +445,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
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}
|
||||
|
||||
workstream_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.order_by(
|
||||
Workstream.planning_priority.asc().nullslast(),
|
||||
Workstream.planning_order.asc().nullslast(),
|
||||
Workstream.updated_at.desc(),
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
Workplan.updated_at.desc(),
|
||||
)
|
||||
)
|
||||
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_totals_by_status: dict[str, int] = {}
|
||||
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)
|
||||
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 = [
|
||||
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]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
@@ -490,19 +528,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"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
|
||||
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)
|
||||
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(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
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(
|
||||
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(
|
||||
id=w.id,
|
||||
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"),
|
||||
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
|
||||
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)
|
||||
ws_per_domain = {}
|
||||
for domain_id, cnt in await session.execute(
|
||||
select(Topic.domain_id, func.count(Workstream.id))
|
||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
select(Topic.domain_id, func.count(Workplan.id))
|
||||
.join(Workplan, Workplan.topic_id == Topic.id)
|
||||
.where(Workplan.status.in_(["active", "blocked"]))
|
||||
.group_by(Topic.domain_id)
|
||||
):
|
||||
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.
|
||||
"""
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
select(Workplan)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
|
||||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
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 = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
|
||||
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
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_task_ids: set = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
dep_ws_ids.add(d.from_workplan_id)
|
||||
if d.to_workplan_id:
|
||||
dep_ws_ids.add(d.to_workplan_id)
|
||||
if 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())
|
||||
if extra_ids:
|
||||
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():
|
||||
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}
|
||||
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:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
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("*"))
|
||||
.where(Decision.status == DecisionStatus.resolved)
|
||||
.where(Decision.decided_at >= cutoff)
|
||||
.where(Decision.workstream_id.isnot(None))
|
||||
.where(Decision.workplan_id.isnot(None))
|
||||
.order_by(Decision.decided_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
@@ -839,7 +877,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
open_tasks_rows = await session.execute(
|
||||
select(Task)
|
||||
.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]))
|
||||
)
|
||||
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))
|
||||
if task.id in seen_task_ids:
|
||||
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)
|
||||
steps.append(NextStep(
|
||||
type="resolved_decision",
|
||||
@@ -868,13 +906,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
||||
all_dep_rows = await session.execute(
|
||||
select(
|
||||
WorkstreamDependency.from_workstream_id,
|
||||
WorkstreamDependency.to_workstream_id,
|
||||
).where(WorkstreamDependency.to_workstream_id.isnot(None))
|
||||
WorkplanDependency.from_workplan_id,
|
||||
WorkplanDependency.to_workplan_id,
|
||||
).where(WorkplanDependency.to_workplan_id.isnot(None))
|
||||
)
|
||||
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_ws_ids = set()
|
||||
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:
|
||||
ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.id,
|
||||
Workstream.status,
|
||||
Workstream.title,
|
||||
Workstream.slug,
|
||||
Workstream.topic_id,
|
||||
).where(Workstream.id.in_(dep_ws_ids))
|
||||
Workplan.id,
|
||||
Workplan.status,
|
||||
Workplan.title,
|
||||
Workplan.slug,
|
||||
Workplan.topic_id,
|
||||
).where(Workplan.id.in_(dep_ws_ids))
|
||||
)
|
||||
ws_info = {
|
||||
ws_id: {
|
||||
@@ -906,9 +944,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
ready_from_ws_ids = [
|
||||
from_ws_id
|
||||
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(
|
||||
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
|
||||
)
|
||||
]
|
||||
@@ -918,11 +956,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
todo_rows = await session.execute(
|
||||
select(Task)
|
||||
.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)
|
||||
)
|
||||
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:
|
||||
from_ws = ws_info.get(from_ws_id, {})
|
||||
@@ -956,7 +994,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
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."""
|
||||
if ws is None or ws.topic_id is None:
|
||||
return None
|
||||
@@ -986,10 +1024,9 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict:
|
||||
async def health_check(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
await session.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "connected"}
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -6,10 +6,18 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.task import TaskCountRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.task import (
|
||||
TaskCountRead,
|
||||
TaskCreate,
|
||||
TaskRead,
|
||||
TaskStatusBulkSync,
|
||||
TaskStatusBulkSyncRead,
|
||||
TaskUpdate,
|
||||
)
|
||||
from api.services.lifecycle import status_value, transition_task_status
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
@@ -18,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
@router.get("/", response_model=list[TaskRead])
|
||||
async def list_tasks(
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
assignee: str | None = None,
|
||||
@@ -29,8 +38,9 @@ async def list_tasks(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Task.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||
if assignee:
|
||||
@@ -52,18 +62,20 @@ async def list_tasks(
|
||||
|
||||
@router.get("/counts", response_model=list[TaskCountRead])
|
||||
async def count_tasks(
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TaskCountRead]:
|
||||
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Task.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
|
||||
rows = await session.execute(q)
|
||||
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
|
||||
]
|
||||
|
||||
@@ -76,7 +88,7 @@ async def create_task(
|
||||
task = Task(**body.model_dump())
|
||||
session.add(task)
|
||||
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(
|
||||
task,
|
||||
task.status,
|
||||
@@ -88,6 +100,84 @@ async def create_task(
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/bulk-status-sync", response_model=TaskStatusBulkSyncRead)
|
||||
async def bulk_status_sync(
|
||||
body: TaskStatusBulkSync,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TaskStatusBulkSyncRead:
|
||||
seen: set[uuid.UUID] = set()
|
||||
duplicate_ids: list[str] = []
|
||||
tasks_by_id: dict[uuid.UUID, Task] = {}
|
||||
missing_ids: list[str] = []
|
||||
|
||||
for update in body.updates:
|
||||
if update.task_id in seen:
|
||||
duplicate_ids.append(str(update.task_id))
|
||||
continue
|
||||
seen.add(update.task_id)
|
||||
task = await session.get(Task, update.task_id)
|
||||
if task is None:
|
||||
missing_ids.append(str(update.task_id))
|
||||
else:
|
||||
tasks_by_id[update.task_id] = task
|
||||
|
||||
if duplicate_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"message": "duplicate task_id values are not allowed", "task_ids": duplicate_ids},
|
||||
)
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"message": "one or more tasks were not found", "task_ids": missing_ids},
|
||||
)
|
||||
|
||||
updated: list[Task] = []
|
||||
events: list[ProgressEvent] = []
|
||||
author = body.author or "custodian"
|
||||
for update in body.updates:
|
||||
task = tasks_by_id[update.task_id]
|
||||
previous_status = status_value(task.status)
|
||||
target_status = status_value(update.status)
|
||||
if update.blocking_reason is not None:
|
||||
task.blocking_reason = update.blocking_reason
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
transition_task_status(
|
||||
task,
|
||||
update.status,
|
||||
parent_workstream=ws,
|
||||
previous_task_status=previous_status,
|
||||
)
|
||||
event = ProgressEvent(
|
||||
task_id=task.id,
|
||||
workplan_id=task.workplan_id,
|
||||
event_type="task_status_changed",
|
||||
summary=f"Task status -> {target_status}: {task.title}",
|
||||
author=author,
|
||||
session_id=body.session_id,
|
||||
detail={
|
||||
"bulk_status_sync": True,
|
||||
"previous_status": previous_status,
|
||||
"status": target_status,
|
||||
"blocking_reason": update.blocking_reason,
|
||||
},
|
||||
)
|
||||
session.add(event)
|
||||
updated.append(task)
|
||||
events.append(event)
|
||||
|
||||
await session.commit()
|
||||
for task in updated:
|
||||
await session.refresh(task)
|
||||
for event in events:
|
||||
await session.refresh(event)
|
||||
|
||||
return TaskStatusBulkSyncRead(
|
||||
updated=updated,
|
||||
progress_event_ids=[event.id for event in events],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskRead)
|
||||
async def get_task(
|
||||
task_id: uuid.UUID,
|
||||
@@ -132,7 +222,7 @@ async def update_task(
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
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(
|
||||
task,
|
||||
status_update,
|
||||
@@ -161,7 +251,7 @@ async def update_task(
|
||||
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
||||
# Tier 2: prorate workplan total across task count
|
||||
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)
|
||||
tin = token_data["workplan_tokens_in"] // task_count
|
||||
@@ -187,12 +277,12 @@ async def update_task(
|
||||
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
|
||||
|
||||
# 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
|
||||
|
||||
event = TokenEvent(
|
||||
task_id=task_id,
|
||||
workstream_id=task.workstream_id,
|
||||
workplan_id=task.workplan_id,
|
||||
repo_id=repo_id,
|
||||
tokens_in=tin,
|
||||
tokens_out=tout,
|
||||
|
||||
@@ -11,7 +11,7 @@ from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
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 (
|
||||
RepoTokenSummary,
|
||||
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]:
|
||||
# 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"])
|
||||
if task:
|
||||
data["workstream_id"] = task.workstream_id
|
||||
data["workplan_id"] = task.workplan_id
|
||||
|
||||
# Auto-populate repo_id from workstream if not provided
|
||||
if data.get("workstream_id") and not data.get("repo_id"):
|
||||
ws = await session.get(Workstream, data["workstream_id"])
|
||||
if data.get("workplan_id") and not data.get("repo_id"):
|
||||
ws = await session.get(Workplan, data["workplan_id"])
|
||||
if ws and ws.repo_id:
|
||||
data["repo_id"] = ws.repo_id
|
||||
return data
|
||||
@@ -169,7 +169,7 @@ def _filter_query(
|
||||
if task_id:
|
||||
q = q.where(TokenEvent.task_id == task_id)
|
||||
if workstream_id:
|
||||
q = q.where(TokenEvent.workstream_id == workstream_id)
|
||||
q = q.where(TokenEvent.workplan_id == workstream_id)
|
||||
if repo_id:
|
||||
q = q.where(TokenEvent.repo_id == repo_id)
|
||||
if ref_type:
|
||||
@@ -195,7 +195,7 @@ def _filter_query(
|
||||
if unattributed:
|
||||
q = q.where(
|
||||
TokenEvent.repo_id.is_(None),
|
||||
TokenEvent.workstream_id.is_(None),
|
||||
TokenEvent.workplan_id.is_(None),
|
||||
TokenEvent.task_id.is_(None),
|
||||
)
|
||||
return q
|
||||
@@ -238,7 +238,7 @@ async def get_token_summary(
|
||||
uid = uuid.UUID(id)
|
||||
except ValueError:
|
||||
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":
|
||||
try:
|
||||
uid = uuid.UUID(id)
|
||||
@@ -297,7 +297,7 @@ async def get_tokens_by_repo(
|
||||
Resolution order for each event:
|
||||
1. token_events.repo_id (direct)
|
||||
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.
|
||||
"""
|
||||
@@ -314,8 +314,8 @@ async def get_tokens_by_repo(
|
||||
)
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
ws_result = await session.execute(select(Workstream))
|
||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
||||
ws_result = await session.execute(select(Workplan))
|
||||
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||
|
||||
task_result = await session.execute(select(Task))
|
||||
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:
|
||||
if 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:
|
||||
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:
|
||||
return ws_map[ws_id].repo_id
|
||||
return None
|
||||
@@ -391,8 +391,8 @@ async def get_token_aggregate(
|
||||
)
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
ws_result = await session.execute(select(Workstream))
|
||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
||||
ws_result = await session.execute(select(Workplan))
|
||||
ws_map: dict[uuid.UUID, Workplan] = {w.id: w for w in ws_result.scalars().all()}
|
||||
|
||||
task_result = await session.execute(select(Task))
|
||||
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:
|
||||
if 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:
|
||||
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:
|
||||
return ws_map[ws_id].repo_id
|
||||
return None
|
||||
@@ -458,7 +458,7 @@ async def get_token_aggregate(
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
if e.source_provider == "task_fallback" or e.note == "heuristic":
|
||||
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
|
||||
if e.measurement_kind == "measured" and not e.source_id:
|
||||
missing_provenance_count += 1
|
||||
|
||||
@@ -30,7 +30,7 @@ async def list_topics(
|
||||
) -> list[Topic]:
|
||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||
q = select(Topic).options(
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.workplans),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.workplan_dependency import WorkplanDependencyCreate, WorkplanDependencyRead
|
||||
from api.routers.workstreams import _legacy_key, _meter_legacy_route
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
||||
@@ -17,28 +17,28 @@ workplan_router = APIRouter(prefix="/workplans", tags=["dependencies"])
|
||||
|
||||
async def _create_dependency(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkplanDependencyCreate,
|
||||
session: AsyncSession,
|
||||
) -> WorkstreamDependency:
|
||||
if await session.get(Workstream, workstream_id) is None:
|
||||
) -> WorkplanDependency:
|
||||
if await session.get(Workplan, workplan_id) is None:
|
||||
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
|
||||
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")
|
||||
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
|
||||
dep = WorkstreamDependency(
|
||||
from_workstream_id=workstream_id,
|
||||
to_workstream_id=body.to_workstream_id,
|
||||
dep = WorkplanDependency(
|
||||
from_workplan_id=workplan_id,
|
||||
to_workplan_id=body.to_workplan_id,
|
||||
to_task_id=body.to_task_id,
|
||||
relationship_type=body.relationship_type,
|
||||
description=body.description,
|
||||
@@ -51,15 +51,15 @@ async def _create_dependency(
|
||||
|
||||
async def _list_dependencies(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> list[WorkstreamDependency]:
|
||||
if await session.get(Workstream, workstream_id) is None:
|
||||
) -> list[WorkplanDependency]:
|
||||
if await session.get(Workplan, workplan_id) is None:
|
||||
raise HTTPException(status_code=404, detail="workplan not found")
|
||||
rows = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id == workstream_id)
|
||||
| (WorkstreamDependency.to_workstream_id == workstream_id)
|
||||
select(WorkplanDependency).where(
|
||||
(WorkplanDependency.from_workplan_id == workplan_id)
|
||||
| (WorkplanDependency.to_workplan_id == workplan_id)
|
||||
)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
@@ -67,14 +67,14 @@ async def _list_dependencies(
|
||||
|
||||
async def _delete_dependency(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
dep_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
dep = await session.get(WorkstreamDependency, dep_id)
|
||||
dep = await session.get(WorkplanDependency, dep_id)
|
||||
if dep is None:
|
||||
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")
|
||||
await session.delete(dep)
|
||||
await session.commit()
|
||||
@@ -82,17 +82,17 @@ async def _delete_dependency(
|
||||
|
||||
@router.post(
|
||||
"/{workstream_id}/dependencies/",
|
||||
response_model=WorkstreamDependencyRead,
|
||||
response_model=WorkplanDependencyRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_dependency(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
body: WorkplanDependencyCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkstreamDependency:
|
||||
"""Record that workstream_id depends on another workstream or a task."""
|
||||
) -> WorkplanDependency:
|
||||
"""Record that workstream_id depends on another workplan or a task."""
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -100,33 +100,33 @@ async def create_dependency(
|
||||
interface_key=_legacy_key("POST", "/workstreams/{workstream_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_id}/dependencies/",
|
||||
response_model=WorkstreamDependencyRead,
|
||||
response_model=WorkplanDependencyRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_workplan_dependency(
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
body: WorkplanDependencyCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkstreamDependency:
|
||||
return await _create_dependency(workstream_id=workplan_id, body=body, session=session)
|
||||
) -> WorkplanDependency:
|
||||
return await _create_dependency(workplan_id=workplan_id, body=body, session=session)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{workstream_id}/dependencies/",
|
||||
response_model=list[WorkstreamDependencyRead],
|
||||
response_model=list[WorkplanDependencyRead],
|
||||
)
|
||||
async def list_dependencies(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkstreamDependency]:
|
||||
"""Return all dependency edges touching this workstream (both directions)."""
|
||||
) -> list[WorkplanDependency]:
|
||||
"""Return all dependency edges touching this workplan (both directions)."""
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -134,18 +134,18 @@ async def list_dependencies(
|
||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_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_id}/dependencies/",
|
||||
response_model=list[WorkstreamDependencyRead],
|
||||
response_model=list[WorkplanDependencyRead],
|
||||
)
|
||||
async def list_workplan_dependencies(
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkstreamDependency]:
|
||||
return await _list_dependencies(workstream_id=workplan_id, session=session)
|
||||
) -> list[WorkplanDependency]:
|
||||
return await _list_dependencies(workplan_id=workplan_id, session=session)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -167,7 +167,7 @@ async def delete_dependency(
|
||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_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(
|
||||
@@ -179,4 +179,4 @@ async def delete_workplan_dependency(
|
||||
dep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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.events import EventEnvelope, publish_event
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.workstream import (
|
||||
WorkstreamCreate,
|
||||
WorkstreamRead,
|
||||
WorkstreamUpdate,
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.workplan import (
|
||||
WorkplanCreate,
|
||||
WorkplanRead,
|
||||
WorkplanUpdate,
|
||||
)
|
||||
from api.services.lifecycle import transition_workstream_status
|
||||
from api.services.lifecycle import transition_workplan_status
|
||||
from api.services.legacy_meter import (
|
||||
LegacyUsageIdentity,
|
||||
identity_from_request,
|
||||
record_legacy_usage,
|
||||
)
|
||||
from api.workplan_status import (
|
||||
is_supported_workstream_status,
|
||||
normalize_workstream_status,
|
||||
is_supported_workplan_status,
|
||||
normalize_workplan_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)
|
||||
|
||||
|
||||
async def _list_workstreams(
|
||||
async def _list_workplans(
|
||||
*,
|
||||
topic_id: uuid.UUID | None,
|
||||
repo_id: uuid.UUID | None,
|
||||
@@ -147,27 +147,27 @@ async def _list_workstreams(
|
||||
owner: str | None,
|
||||
slug: str | None,
|
||||
session: AsyncSession,
|
||||
) -> list[Workstream]:
|
||||
q = select(Workstream)
|
||||
) -> list[Workplan]:
|
||||
q = select(Workplan)
|
||||
if topic_id:
|
||||
q = q.where(Workstream.topic_id == topic_id)
|
||||
q = q.where(Workplan.topic_id == topic_id)
|
||||
if repo_id:
|
||||
q = q.where(Workstream.repo_id == repo_id)
|
||||
q = q.where(Workplan.repo_id == repo_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:
|
||||
normalised_status = normalize_workstream_status(status_filter)
|
||||
if not is_supported_workstream_status(status_filter):
|
||||
normalised_status = normalize_workplan_status(status_filter)
|
||||
if not is_supported_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:
|
||||
q = q.where(Workstream.owner == owner)
|
||||
q = q.where(Workplan.owner == owner)
|
||||
if slug:
|
||||
q = q.where(Workstream.slug == slug)
|
||||
q = q.where(Workplan.slug == slug)
|
||||
q = q.order_by(
|
||||
Workstream.planning_priority.asc().nullslast(),
|
||||
Workstream.planning_order.asc().nullslast(),
|
||||
Workstream.updated_at.desc(),
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
Workplan.updated_at.desc(),
|
||||
)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
data = _frontmatter(path)
|
||||
workstream_id = data.get("state_hub_workstream_id")
|
||||
if not workstream_id:
|
||||
workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
|
||||
if not workplan_id:
|
||||
continue
|
||||
file_status = normalize_workstream_status(data.get("status", ""))
|
||||
file_status = normalize_workplan_status(data.get("status", ""))
|
||||
review = (
|
||||
ready_review_status(
|
||||
root,
|
||||
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
|
||||
if file_status == "ready"
|
||||
else None
|
||||
)
|
||||
index[str(workstream_id)] = {
|
||||
index[str(workplan_id)] = {
|
||||
"filename": path.name,
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"repo_slug": repo.slug,
|
||||
@@ -287,79 +287,79 @@ async def _workplan_index(
|
||||
return _INDEX_CACHE
|
||||
|
||||
|
||||
async def _create_workstream(
|
||||
async def _create_workplan(
|
||||
*,
|
||||
body: WorkstreamCreate,
|
||||
body: WorkplanCreate,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = Workstream(**body.model_dump())
|
||||
session.add(ws)
|
||||
) -> Workplan:
|
||||
wp = Workplan(**body.model_dump())
|
||||
session.add(wp)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
await session.refresh(wp)
|
||||
return wp
|
||||
|
||||
|
||||
async def _get_workstream(
|
||||
async def _get_workplan(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
) -> Workplan:
|
||||
wp = await session.get(Workplan, workplan_id)
|
||||
if wp is None:
|
||||
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,
|
||||
body: WorkstreamUpdate,
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkplanUpdate,
|
||||
session: AsyncSession,
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
) -> Workplan:
|
||||
wp = await session.get(Workplan, workplan_id)
|
||||
if wp is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
status_update = update_data.pop("status", None)
|
||||
prev_status = ws.status
|
||||
prev_status = wp.status
|
||||
for field, value in update_data.items():
|
||||
setattr(ws, field, value)
|
||||
setattr(wp, field, value)
|
||||
if status_update is not None:
|
||||
transition_workstream_status(ws, status_update)
|
||||
transition_workplan_status(wp, status_update)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
await session.refresh(wp)
|
||||
|
||||
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
|
||||
await _publish_completion_events(ws, session)
|
||||
if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
|
||||
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,
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
) -> Workplan:
|
||||
wp = await session.get(Workplan, workplan_id)
|
||||
if wp is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
transition_workstream_status(ws, "archived")
|
||||
transition_workplan_status(wp, "archived")
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
await session.refresh(wp)
|
||||
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(
|
||||
_COMPLETED_WORKPLAN_EVENT,
|
||||
attributes={
|
||||
"workplan_id": str(ws.id),
|
||||
"legacy_workstream_id": str(ws.id),
|
||||
"slug": ws.slug,
|
||||
"title": ws.title,
|
||||
"topic_id": str(ws.topic_id),
|
||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
||||
"workplan_id": str(wp.id),
|
||||
"legacy_workstream_id": str(wp.id),
|
||||
"slug": wp.slug,
|
||||
"title": wp.title,
|
||||
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||
"repo_id": str(wp.repo_id) if wp.repo_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))
|
||||
@@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N
|
||||
legacy_envelope = EventEnvelope.new(
|
||||
_COMPLETED_WORKSTREAM_EVENT,
|
||||
attributes={
|
||||
"workstream_id": str(ws.id),
|
||||
"slug": ws.slug,
|
||||
"title": ws.title,
|
||||
"topic_id": str(ws.topic_id),
|
||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
||||
"workstream_id": str(wp.id),
|
||||
"slug": wp.slug,
|
||||
"title": wp.title,
|
||||
"topic_id": str(wp.topic_id) if wp.topic_id else None,
|
||||
"repo_id": str(wp.repo_id) if wp.repo_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))
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
@router.get("/", response_model=list[WorkplanRead])
|
||||
async def list_workstreams(
|
||||
request: Request,
|
||||
response: Response,
|
||||
@@ -394,7 +394,7 @@ async def list_workstreams(
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
) -> list[Workplan]:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -402,7 +402,7 @@ async def list_workstreams(
|
||||
interface_key=_legacy_key("GET", "/workstreams/"),
|
||||
replacement_ref="/workplans/",
|
||||
)
|
||||
return await _list_workstreams(
|
||||
return await _list_workplans(
|
||||
topic_id=topic_id,
|
||||
repo_id=repo_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(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
@@ -422,8 +422,8 @@ async def list_workplans(
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
return await _list_workstreams(
|
||||
) -> list[Workplan]:
|
||||
return await _list_workplans(
|
||||
topic_id=topic_id,
|
||||
repo_id=repo_id,
|
||||
repo_goal_id=repo_goal_id,
|
||||
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
|
||||
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(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: WorkstreamCreate,
|
||||
body: WorkplanCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -473,24 +473,24 @@ async def create_workstream(
|
||||
interface_key=_legacy_key("POST", "/workstreams/"),
|
||||
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(
|
||||
body: WorkstreamCreate,
|
||||
body: WorkplanCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _create_workstream(body=body, session=session)
|
||||
) -> Workplan:
|
||||
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(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -498,25 +498,25 @@ async def get_workstream(
|
||||
interface_key=_legacy_key("GET", "/workstreams/{workstream_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(
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _get_workstream(workstream_id=workplan_id, session=session)
|
||||
) -> Workplan:
|
||||
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(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
body: WorkplanUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -524,25 +524,25 @@ async def update_workstream(
|
||||
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_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(
|
||||
workplan_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
body: WorkplanUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
|
||||
) -> Workplan:
|
||||
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(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
) -> Workplan:
|
||||
await _meter_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
@@ -550,12 +550,12 @@ async def archive_workstream(
|
||||
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_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(
|
||||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
return await _archive_workstream(workstream_id=workplan_id, session=session)
|
||||
) -> Workplan:
|
||||
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.workplan import WorkplanCreate, WorkplanUpdate, WorkplanRead
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||
@@ -9,6 +10,7 @@ from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
|
||||
|
||||
__all__ = [
|
||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||
"WorkplanCreate", "WorkplanUpdate", "WorkplanRead",
|
||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||
|
||||
@@ -1,43 +1,15 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CatalogCreate(BaseModel):
|
||||
domain: str # slug, resolved to domain_id in router
|
||||
capability_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
keywords: list[str] = []
|
||||
repo_slug: str | None = None # optional repo attribution
|
||||
|
||||
|
||||
class CatalogPatch(BaseModel):
|
||||
repo_slug: str | None = None
|
||||
description: str | None = None
|
||||
keywords: list[str] | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class CatalogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
domain_slug: str
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_slug: str | None = None
|
||||
capability_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
keywords: list[str] = []
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
from hub_core.schemas.capability import (
|
||||
CapabilityRequestDispute,
|
||||
CapabilityRequestStatusPatch,
|
||||
CatalogCreate,
|
||||
CatalogPatch,
|
||||
CatalogRead,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -51,31 +23,29 @@ class CapabilityRequestCreate(BaseModel):
|
||||
priority: str = "medium"
|
||||
requesting_domain: str # slug, resolved to domain_id in router
|
||||
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
|
||||
|
||||
|
||||
class CapabilityRequestAccept(BaseModel):
|
||||
fulfilling_agent: str
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestStatusPatch(BaseModel):
|
||||
status: str # in_progress | ready_for_review | completed | rejected | withdrawn
|
||||
note: str | None = None
|
||||
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class CapabilityRequestPatch(BaseModel):
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
priority: str | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestDispute(BaseModel):
|
||||
reason: str
|
||||
disputed_by: str
|
||||
suggested_domain: str | None = None
|
||||
fulfilling_workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
|
||||
)
|
||||
|
||||
|
||||
class CapabilityRequestReroute(BaseModel):
|
||||
@@ -96,10 +66,10 @@ class CapabilityRequestRead(BaseModel):
|
||||
status: str
|
||||
requesting_domain_slug: 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_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
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
resolution_note: str | None = None
|
||||
@@ -112,3 +82,13 @@ class CapabilityRequestRead(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
created_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
|
||||
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
|
||||
|
||||
@@ -14,7 +14,10 @@ class ContributionCreate(BaseModel):
|
||||
title: str
|
||||
body_path: str | 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
|
||||
notes: str | None = None
|
||||
|
||||
@@ -36,10 +39,15 @@ class ContributionRead(BaseModel):
|
||||
status: ContributionStatus
|
||||
body_path: str | 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
|
||||
submitted_at: datetime | None = None
|
||||
resolved_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
created_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 api.models.decision import DecisionStatus, DecisionType
|
||||
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
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
|
||||
description: str | None = None
|
||||
decision_type: DecisionType = DecisionType.pending
|
||||
@@ -20,9 +25,9 @@ class DecisionCreate(BaseModel):
|
||||
escalation_note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workstream_id is None:
|
||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
||||
def topic_or_workplan_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workplan_id is None:
|
||||
raise ValueError("At least one of topic_id or workplan_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
@@ -45,11 +50,10 @@ class DecisionUpdate(BaseModel):
|
||||
superseded_by: uuid.UUID | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType
|
||||
@@ -61,4 +65,4 @@ class DecisionRead(BaseModel):
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
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"]
|
||||
@@ -21,7 +21,12 @@ class ExecutionIntentUpdate(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
|
||||
launch_mode: LaunchMode
|
||||
concurrency_mode: ConcurrencyMode
|
||||
@@ -31,7 +36,7 @@ class ExecutionIntentRead(BaseModel):
|
||||
|
||||
|
||||
class WorkplanQueueItem(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
status: str
|
||||
@@ -45,13 +50,18 @@ class WorkplanQueueItem(BaseModel):
|
||||
execution_group: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
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)
|
||||
sort_key: list[str | int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LaunchRequestCreate(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID = Field(validation_alias=AliasChoices("workplan_id", "workstream_id"))
|
||||
requested_by: str = "dashboard"
|
||||
requested_actor: str | None = None
|
||||
launch_mode: LaunchMode = "queued"
|
||||
@@ -67,10 +77,15 @@ class LaunchRequestCreate(BaseModel):
|
||||
class LaunchRequestRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
workplan_id: uuid.UUID
|
||||
requested_by: str
|
||||
requested_actor: str | None = None
|
||||
launch_mode: LaunchMode
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID:
|
||||
return self.workplan_id
|
||||
concurrency_mode: ConcurrencyMode
|
||||
priority: str | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
|
||||
from api.models.extension_point import EPStatus
|
||||
|
||||
@@ -18,7 +18,10 @@ class EPCreate(BaseModel):
|
||||
status: EPStatus = EPStatus.open
|
||||
priority: str = "medium"
|
||||
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):
|
||||
@@ -29,7 +32,10 @@ class EPUpdate(BaseModel):
|
||||
ep_type: str | None = None
|
||||
status: EPStatus | 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):
|
||||
@@ -45,6 +51,10 @@ class EPRead(BaseModel):
|
||||
status: EPStatus
|
||||
priority: str
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID | None:
|
||||
return self.workplan_id
|
||||
updated_at: datetime
|
||||
|
||||
@@ -2,21 +2,84 @@ import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, 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 (
|
||||
RepoCreate as CoreRepoCreate,
|
||||
RepoPathRegister,
|
||||
RepoRead as CoreRepoRead,
|
||||
)
|
||||
|
||||
|
||||
class RepoCreate(BaseModel):
|
||||
domain_slug: str
|
||||
slug: str
|
||||
name: str
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
git_fingerprint: str | None = None
|
||||
description: str | None = None
|
||||
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
|
||||
|
||||
@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
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
@@ -26,12 +89,6 @@ class RepoUpdate(BaseModel):
|
||||
last_state_synced_at: datetime | None = None
|
||||
|
||||
|
||||
class RepoPathRegister(BaseModel):
|
||||
"""Register a machine-local path for a repo on a specific host."""
|
||||
host: str
|
||||
path: str
|
||||
|
||||
|
||||
class RepoOnboardRequest(BaseModel):
|
||||
"""Start scripted onboarding for a working copy that is visible to State Hub."""
|
||||
domain_slug: str
|
||||
@@ -49,19 +106,7 @@ class RepoOnboardResult(BaseModel):
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
class RepoRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
domain_id: uuid.UUID
|
||||
domain_slug: str # derived from domain relationship
|
||||
slug: str
|
||||
name: str
|
||||
local_path: str | None = None
|
||||
host_paths: dict = {}
|
||||
remote_url: str | None = None
|
||||
git_fingerprint: str | None = None
|
||||
description: str | None = None
|
||||
status: str
|
||||
class RepoRead(CoreRepoRead, ClassificationFields):
|
||||
topic_id: uuid.UUID | None = None
|
||||
sbom_source: str | None = None
|
||||
last_sbom_at: datetime | None = None
|
||||
@@ -78,13 +123,17 @@ class DispatchTask(BaseModel):
|
||||
needs_human: bool
|
||||
|
||||
|
||||
class DispatchWorkstream(BaseModel):
|
||||
class DispatchWorkplan(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
status: str
|
||||
pending_tasks: list[DispatchTask]
|
||||
|
||||
|
||||
# Legacy alias
|
||||
DispatchWorkstream = DispatchWorkplan
|
||||
|
||||
|
||||
class PendingInterfaceChange(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
@@ -109,13 +158,17 @@ class ScopeIssueDetail(BaseModel):
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workstreams: list[DispatchWorkstream]
|
||||
active_workplans: list[DispatchWorkplan]
|
||||
human_interventions: list[DispatchTask]
|
||||
pending_interface_changes: list[PendingInterfaceChange]
|
||||
scope_needs_review: bool
|
||||
scope_issue_details: list[ScopeIssueDetail]
|
||||
last_state_synced_at: datetime | None
|
||||
|
||||
@property
|
||||
def active_workstreams(self) -> list[DispatchWorkplan]:
|
||||
return self.active_workplans
|
||||
|
||||
|
||||
class RepoScopeHealth(BaseModel):
|
||||
repo_slug: str
|
||||
@@ -123,4 +176,4 @@ class RepoScopeHealth(BaseModel):
|
||||
local_path: str | None = None
|
||||
path_available: 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 typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
|
||||
from api.schemas.compat import OptionalWorkplanIdCompatMixin
|
||||
|
||||
|
||||
class ProgressEventCreate(BaseModel):
|
||||
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
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
@@ -17,11 +22,10 @@ class ProgressEventCreate(BaseModel):
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class ProgressEventRead(BaseModel):
|
||||
class ProgressEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
@@ -29,4 +33,4 @@ class ProgressEventRead(BaseModel):
|
||||
detail: dict[str, Any] | None = None
|
||||
author: 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 api.models.task import TaskPriority, TaskStatus
|
||||
from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
|
||||
@@ -17,8 +18,7 @@ class TaskStatusMixin(BaseModel):
|
||||
return normalize_task_status(value)
|
||||
|
||||
|
||||
class TaskCreate(TaskStatusMixin):
|
||||
workstream_id: uuid.UUID
|
||||
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
@@ -77,10 +77,28 @@ class TaskUpdate(TaskStatusMixin):
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(TaskStatusMixin):
|
||||
class TaskStatusBulkUpdate(TaskStatusMixin):
|
||||
task_id: uuid.UUID
|
||||
status: TaskStatus
|
||||
blocking_reason: str | None = None
|
||||
|
||||
|
||||
class TaskStatusBulkSync(BaseModel):
|
||||
updates: list[TaskStatusBulkUpdate]
|
||||
author: str | None = "custodian"
|
||||
session_id: str | None = None
|
||||
|
||||
@field_validator("updates")
|
||||
@classmethod
|
||||
def updates_required(cls, value: list[TaskStatusBulkUpdate]):
|
||||
if not value:
|
||||
raise ValueError("at least one task status update is required")
|
||||
return value
|
||||
|
||||
|
||||
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
@@ -95,7 +113,11 @@ class TaskRead(TaskStatusMixin):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TaskCountRead(TaskStatusMixin):
|
||||
workstream_id: uuid.UUID
|
||||
class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||
status: TaskStatus
|
||||
count: int
|
||||
|
||||
|
||||
class TaskStatusBulkSyncRead(BaseModel):
|
||||
updated: list[TaskRead]
|
||||
progress_event_ids: list[uuid.UUID]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
|
||||
from api.models.technical_debt import TDStatus
|
||||
|
||||
@@ -35,7 +35,10 @@ class TDCreate(BaseModel):
|
||||
severity: str = "medium"
|
||||
status: TDStatus = TDStatus.open
|
||||
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):
|
||||
@@ -45,7 +48,10 @@ class TDUpdate(BaseModel):
|
||||
debt_type: str | None = None
|
||||
severity: str | 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):
|
||||
@@ -61,7 +67,11 @@ class TDRead(BaseModel):
|
||||
severity: str
|
||||
status: TDStatus
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def workstream_id(self) -> uuid.UUID | None:
|
||||
return self.workplan_id
|
||||
updated_at: datetime
|
||||
notes: list[TDNoteRead] = []
|
||||
|
||||
@@ -2,14 +2,19 @@ import uuid
|
||||
from datetime import datetime
|
||||
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):
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
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
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
@@ -32,14 +37,13 @@ class TokenEventCreate(BaseModel):
|
||||
raw_metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TokenEventRead(BaseModel):
|
||||
class TokenEventRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
task_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
@@ -90,7 +94,10 @@ class TokenEventPatch(BaseModel):
|
||||
tokens_in: int | None = None
|
||||
tokens_out: int | 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
|
||||
session_id: 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
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
"""Legacy aliases — prefer ``api.schemas.workplan``."""
|
||||
from api.schemas.workplan import (
|
||||
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
|
||||
from api.workplan_status import normalize_workstream_status
|
||||
|
||||
WorkstreamStatus = 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 WorkstreamStatusMixin(BaseModel):
|
||||
@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] = []
|
||||
__all__ = [
|
||||
"WorkstreamStatus",
|
||||
"WorkstreamStatusMixin",
|
||||
"WorkstreamCreate",
|
||||
"WorkstreamUpdate",
|
||||
"WorkstreamRead",
|
||||
"WorkstreamWithTaskCounts",
|
||||
"WorkstreamWithDeps",
|
||||
"WorkplanStatus",
|
||||
"WorkplanStatusMixin",
|
||||
"WorkplanCreate",
|
||||
"WorkplanUpdate",
|
||||
"WorkplanRead",
|
||||
"WorkplanWithTaskCounts",
|
||||
"WorkplanWithDeps",
|
||||
"ExecutionState",
|
||||
"LaunchMode",
|
||||
"ConcurrencyMode",
|
||||
]
|
||||
@@ -1,36 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
"""Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
|
||||
from api.schemas.workplan_dependency import (
|
||||
WorkplanDepStub,
|
||||
WorkplanDependencyCreate,
|
||||
WorkplanDependencyRead,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
WorkstreamDependencyCreate = WorkplanDependencyCreate
|
||||
WorkstreamDependencyRead = WorkplanDependencyRead
|
||||
WorkstreamDepStub = WorkplanDepStub
|
||||
|
||||
|
||||
class WorkstreamDependencyCreate(BaseModel):
|
||||
to_workstream_id: uuid.UUID | None = None
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str = "blocks"
|
||||
description: str | None = None
|
||||
|
||||
|
||||
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
|
||||
__all__ = [
|
||||
"WorkstreamDependencyCreate",
|
||||
"WorkstreamDependencyRead",
|
||||
"WorkstreamDepStub",
|
||||
"WorkplanDependencyCreate",
|
||||
"WorkplanDependencyRead",
|
||||
"WorkplanDepStub",
|
||||
]
|
||||
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 api.workplan_status import normalize_workstream_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
|
||||
EXECUTION_STATES = {
|
||||
@@ -57,7 +57,7 @@ PRIORITY_RANK = {
|
||||
"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:
|
||||
@@ -71,19 +71,24 @@ def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False)
|
||||
return "queued"
|
||||
|
||||
|
||||
def workstream_blockers(
|
||||
workstream_id: Any,
|
||||
def workplan_blockers(
|
||||
workplan_id: Any,
|
||||
dependency_targets: dict[Any, list[Any]],
|
||||
workstream_status: dict[Any, str],
|
||||
workplan_status: dict[Any, str],
|
||||
workstream_id: Any = None,
|
||||
) -> list[Any]:
|
||||
scope_id = workplan_id if workplan_id is not None else workstream_id
|
||||
blockers = []
|
||||
for target_id in dependency_targets.get(workstream_id, []):
|
||||
target_status = normalize_workstream_status(workstream_status.get(target_id))
|
||||
if target_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||
for target_id in dependency_targets.get(scope_id, []):
|
||||
target_status = normalize_workplan_status(workplan_status.get(target_id))
|
||||
if target_status not in CLOSED_WORKPLAN_STATUSES:
|
||||
blockers.append(target_id)
|
||||
return blockers
|
||||
|
||||
|
||||
workstream_blockers = workplan_blockers
|
||||
|
||||
|
||||
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
|
||||
priority = str(getattr(workstream, "planning_priority", "") or "").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 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_NOT_STARTED_STATUS = "todo"
|
||||
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
|
||||
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
|
||||
|
||||
# Legacy alias
|
||||
normalize_workstream_status = normalize_workplan_status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleTransitionResult:
|
||||
@@ -26,13 +28,15 @@ def should_activate_parent_for_task_start(
|
||||
*,
|
||||
previous_task_status: Any,
|
||||
new_task_status: Any,
|
||||
parent_workstream_status: Any,
|
||||
parent_workplan_status: Any = None,
|
||||
parent_workstream_status: Any = None,
|
||||
) -> bool:
|
||||
"""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 (
|
||||
status_value(previous_task_status) == TASK_NOT_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
|
||||
)
|
||||
|
||||
@@ -44,12 +48,14 @@ def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||
|
||||
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, ...],
|
||||
) -> 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 (
|
||||
normalize_workstream_status(parent_workstream_status)
|
||||
normalize_workplan_status(parent_status)
|
||||
in PARENT_ACTIVATION_STATUSES
|
||||
and has_active_task_status(task_statuses)
|
||||
)
|
||||
@@ -59,46 +65,54 @@ def activate_parent_for_task_start(
|
||||
*,
|
||||
previous_task_status: Any,
|
||||
new_task_status: Any,
|
||||
parent_workstream: Any,
|
||||
parent_workplan: Any = None,
|
||||
parent_workstream: Any = None,
|
||||
) -> bool:
|
||||
"""Activate a planning-state parent workstream when real task work starts."""
|
||||
if parent_workstream is None:
|
||||
"""Activate a planning-state parent workplan when real task work starts."""
|
||||
parent = parent_workplan if parent_workplan is not None else parent_workstream
|
||||
if parent is None:
|
||||
return False
|
||||
if not should_activate_parent_for_task_start(
|
||||
previous_task_status=previous_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
|
||||
parent_workstream.status = "active"
|
||||
parent.status = "active"
|
||||
return True
|
||||
|
||||
|
||||
def transition_workstream_status(
|
||||
workstream: Any,
|
||||
def transition_workplan_status(
|
||||
workplan: Any,
|
||||
target_status: Any,
|
||||
) -> LifecycleTransitionResult:
|
||||
"""Apply a canonical workstream status transition."""
|
||||
previous_status = normalize_workstream_status(getattr(workstream, "status", None))
|
||||
normalised_target = normalize_workstream_status(target_status)
|
||||
workstream.status = normalised_target
|
||||
"""Apply a canonical workplan status transition."""
|
||||
previous_status = normalize_workplan_status(getattr(workplan, "status", None))
|
||||
normalised_target = normalize_workplan_status(target_status)
|
||||
workplan.status = normalised_target
|
||||
return LifecycleTransitionResult(
|
||||
entity_type="workstream",
|
||||
entity_type="workplan",
|
||||
previous_status=previous_status,
|
||||
target_status=normalised_target,
|
||||
changed=previous_status != normalised_target,
|
||||
)
|
||||
|
||||
|
||||
transition_workstream_status = transition_workplan_status
|
||||
|
||||
|
||||
def transition_task_status(
|
||||
task: Any,
|
||||
target_status: Any,
|
||||
*,
|
||||
parent_workplan: Any = None,
|
||||
parent_workstream: Any = None,
|
||||
previous_task_status: Any = None,
|
||||
status_coercer: Any = None,
|
||||
) -> LifecycleTransitionResult:
|
||||
"""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(
|
||||
getattr(task, "status", None)
|
||||
if previous_task_status is None
|
||||
@@ -109,7 +123,8 @@ def transition_task_status(
|
||||
parent_activated = activate_parent_for_task_start(
|
||||
previous_task_status=previous_status,
|
||||
new_task_status=normalised_target,
|
||||
parent_workstream=parent_workstream,
|
||||
parent_workplan=parent,
|
||||
parent_workstream=parent,
|
||||
)
|
||||
return LifecycleTransitionResult(
|
||||
entity_type="task",
|
||||
@@ -117,4 +132,4 @@ def transition_task_status(
|
||||
target_status=normalised_target,
|
||||
changed=previous_status != normalised_target,
|
||||
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.task import Task, TaskStatus
|
||||
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 (
|
||||
RecentlyOnScopeFailedDomain,
|
||||
RecentlyOnScopeHourlyRun,
|
||||
@@ -344,11 +344,11 @@ async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topi
|
||||
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(
|
||||
select(Workstream)
|
||||
.where(_in(Workstream.topic_id, topic_ids))
|
||||
.order_by(Workstream.updated_at.desc(), Workstream.created_at.desc())
|
||||
select(Workplan)
|
||||
.where(_in(Workplan.topic_id, topic_ids))
|
||||
.order_by(Workplan.updated_at.desc(), Workplan.created_at.desc())
|
||||
)
|
||||
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]:
|
||||
result = await session.execute(
|
||||
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())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -370,7 +370,7 @@ async def _list_recent_decisions(
|
||||
) -> list[Decision]:
|
||||
result = await session.execute(
|
||||
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(
|
||||
or_(
|
||||
_between(Decision.created_at, window),
|
||||
@@ -397,7 +397,7 @@ async def _list_recent_progress(
|
||||
.where(
|
||||
or_(
|
||||
_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.decision_id, decision_ids),
|
||||
)
|
||||
@@ -550,7 +550,8 @@ def _progress_data(event: ProgressEvent) -> dict[str, Any]:
|
||||
"event_type": event.event_type,
|
||||
"summary": event.summary,
|
||||
"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,
|
||||
"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 {
|
||||
"id": str(workstream.id),
|
||||
"slug": workstream.slug,
|
||||
@@ -584,7 +585,7 @@ def _workstream_data(workstream: Workstream) -> dict[str, Any]:
|
||||
def _task_data(task: Task) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"workstream_id": str(task.workstream_id),
|
||||
"workstream_id": str(task.workplan_id),
|
||||
"title": task.title,
|
||||
"status": _enum_value(task.status),
|
||||
"priority": _enum_value(task.priority),
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from api.services.lifecycle import status_value
|
||||
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):
|
||||
@@ -22,11 +22,11 @@ class StateChangeClassification:
|
||||
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)
|
||||
|
||||
|
||||
def classify_workstream_status_change(
|
||||
def classify_workplan_status_change(
|
||||
*,
|
||||
current_status: Any,
|
||||
target_status: Any,
|
||||
@@ -35,8 +35,8 @@ def classify_workstream_status_change(
|
||||
tasks_terminal: bool | None = None,
|
||||
) -> StateChangeClassification:
|
||||
"""Classify a UI-originated workstream status transition."""
|
||||
current = normalize_workstream_status(current_status)
|
||||
target = normalize_workstream_status(target_status)
|
||||
current = normalize_workplan_status(current_status)
|
||||
target = normalize_workplan_status(target_status)
|
||||
|
||||
if not file_backed:
|
||||
return StateChangeClassification(
|
||||
@@ -56,7 +56,7 @@ def classify_workstream_status_change(
|
||||
"status is unchanged",
|
||||
"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(
|
||||
ReconciliationClass.WRITE_THROUGH,
|
||||
"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(
|
||||
*,
|
||||
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
|
||||
|
||||
|
||||
def find_workplan_for_workstream(
|
||||
def find_workplan_for_workplan(
|
||||
repo: ManagedRepo | None,
|
||||
workstream_id: uuid.UUID,
|
||||
workplan_id: uuid.UUID,
|
||||
) -> WorkplanFileRef | None:
|
||||
repo_path = resolve_repo_path(repo)
|
||||
if repo_path is None:
|
||||
@@ -57,11 +57,15 @@ def find_workplan_for_workstream(
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
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 None
|
||||
|
||||
|
||||
find_workplan_for_workstream = find_workplan_for_workplan
|
||||
|
||||
|
||||
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
|
||||
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
|
||||
|
||||
|
||||
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||
"proposed",
|
||||
"ready",
|
||||
"active",
|
||||
@@ -17,22 +17,31 @@ CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
"archived",
|
||||
)
|
||||
|
||||
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
|
||||
LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = {
|
||||
"todo": "ready",
|
||||
"done": "finished",
|
||||
"completed": "finished",
|
||||
"accepted": "finished",
|
||||
}
|
||||
|
||||
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||
*CANONICAL_WORKSTREAM_STATUSES,
|
||||
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(),
|
||||
SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
|
||||
*CANONICAL_WORKPLAN_STATUSES,
|
||||
*LEGACY_WORKPLAN_STATUS_ALIASES.keys(),
|
||||
)
|
||||
|
||||
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||
OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||
CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||
CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||
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)
|
||||
@@ -42,26 +51,38 @@ class ReadyReviewStatus:
|
||||
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."""
|
||||
value = _status_value(status)
|
||||
if value == "todo" and has_started:
|
||||
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:
|
||||
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
|
||||
normalize_workstream_status = normalize_workplan_status
|
||||
|
||||
|
||||
def is_supported_workstream_status(status: Any) -> bool:
|
||||
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
|
||||
def is_canonical_workplan_status(status: Any) -> bool:
|
||||
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)
|
||||
|
||||
|
||||
workstream_has_started = workplan_has_started
|
||||
|
||||
|
||||
def ready_review_status(
|
||||
repo_dir: str | Path,
|
||||
reviewed_against_commit: Any,
|
||||
|
||||
@@ -126,11 +126,9 @@ def _detect_domain(project_path: Path) -> str | None:
|
||||
|
||||
|
||||
def _check_mcp() -> bool:
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if not claude_json.exists():
|
||||
return False
|
||||
config = json.loads(claude_json.read_text())
|
||||
return "state-hub" in config.get("mcpServers", {})
|
||||
from scripts.mcp_registration import load_claude_json, mcp_server_registered
|
||||
|
||||
return mcp_server_registered(load_claude_json())
|
||||
|
||||
|
||||
# ── Subcommands ────────────────────────────────────────────────────────────────
|
||||
@@ -193,7 +191,8 @@ def cmd_register(args: argparse.Namespace) -> None:
|
||||
if _check_mcp():
|
||||
print(" MCP OK")
|
||||
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.")
|
||||
|
||||
# ── Step 5: Write CLAUDE.custodian.md ─────────────────────────────────────
|
||||
@@ -466,6 +465,55 @@ def cmd_status(_args: argparse.Namespace) -> None:
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
@@ -550,12 +598,47 @@ def main() -> None:
|
||||
ctask.add_argument("--assignee", 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
|
||||
sub.add_parser("status", help="Show State Hub health and summary totals")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "register":
|
||||
if hasattr(args, "func"):
|
||||
args.func(args)
|
||||
elif args.command == "register":
|
||||
run_statehub_register(args)
|
||||
elif args.command == "register-project":
|
||||
cmd_register(args)
|
||||
|
||||
@@ -34,7 +34,16 @@ export default {
|
||||
{ name: "Inbox", path: "/inbox" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
{ 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: "Tools & Apps", path: "/tools" },
|
||||
// ── Sections (alphabetical) ───────────────────────────────────────────────
|
||||
@@ -44,6 +53,7 @@ export default {
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Repository DoI", path: "/policy/repo-doi" },
|
||||
{ name: "Service DoM", path: "/policy/service-dom" },
|
||||
{ name: "Workstream DoD", path: "/policy/workstream-dod" },
|
||||
],
|
||||
},
|
||||
@@ -103,6 +113,7 @@ export default {
|
||||
{ name: "Repos", path: "/docs/repos" },
|
||||
{ name: "SBOM", path: "/docs/sbom" },
|
||||
{ name: "SCOPE.md", path: "/docs/scope" },
|
||||
{ name: "Service Catalog", path: "/docs/services" },
|
||||
{ name: "Tasks", path: "/docs/tasks" },
|
||||
{ name: "TPSC", path: "/docs/tpsc" },
|
||||
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
|
||||
|
||||
@@ -31,8 +31,8 @@ export function isOpenWorkstream(status) {
|
||||
return OPEN_WORKSTREAM_STATUSES.includes(normalizeWorkstreamStatus(status));
|
||||
}
|
||||
|
||||
export function isStalledWorkstream(w, staleDays = 7) {
|
||||
const staleAt = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000);
|
||||
export function isStalledWorkstream(w) {
|
||||
const staleAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const openTasks = (w.todo ?? 0) + (w.progress ?? 0) + (w.wait ?? 0);
|
||||
return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status))
|
||||
&& new Date(w.updated_at) < staleAt
|
||||
|
||||
@@ -4,27 +4,36 @@ title: Domains — Reference
|
||||
|
||||
# Domains — Reference
|
||||
|
||||
The Domains page shows all registered project domains and the repositories
|
||||
associated with each one. Domains are the top-level organisational unit of the
|
||||
Custodian ecosystem.
|
||||
The Domains page shows the **14 fixed market domains** from the Repo
|
||||
Classification Standard. These replaced the old ad-hoc coordination domains
|
||||
(custodian, railiance, markitect, …) in STATE-WP-0065.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `railiance` | DevOps & infrastructure reliability |
|
||||
| `markitect` | Knowledge artifact management |
|
||||
| `coulomb_social` | Co-creation marketplace |
|
||||
| `personhood` | Rights & obligations framework |
|
||||
| `foerster_capabilities` | Agency capability taxonomy |
|
||||
| `infotech` | Developers, platforms, internal tooling users |
|
||||
| `financials` | Finance, trading, payments |
|
||||
| `communication` | Messaging, social, collaboration |
|
||||
| `consumer` | General consumers |
|
||||
| `health` | Healthcare, wellness |
|
||||
| `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
|
||||
optional description, and a status.
|
||||
Canon: `the-custodian/canon/standards/repo-classification-standard_v1.0.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -32,63 +41,21 @@ optional description, and a status.
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **active** | Live domain — topics, workstreams, and tasks are being tracked |
|
||||
| **archived** | Soft-deleted; no active work. Fails to archive if active topics exist |
|
||||
| **active** | Live domain — repos and workplans may reference it |
|
||||
| **archived** | Retired; no new registrations |
|
||||
|
||||
---
|
||||
|
||||
## KPI row
|
||||
## Relationship to repos and workplans
|
||||
|
||||
Four counters at the top of the page:
|
||||
|
||||
| Counter | Meaning |
|
||||
|---------|---------|
|
||||
| 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 |
|
||||
- **Repos** are the primary anchor — classification file is source of truth.
|
||||
- **Workplans** require `repo_id`; market domain is derived from the repo.
|
||||
- **Topics** are optional legacy tags; workplan frontmatter `domain:` may still
|
||||
use old coordination slugs — the consistency checker maps these to market domains.
|
||||
|
||||
---
|
||||
|
||||
## Domain cards
|
||||
## Related
|
||||
|
||||
One card per domain showing:
|
||||
|
||||
- **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.*
|
||||
- **[Repos](/docs/repos)** — portfolio view with category / capability filters
|
||||
- **[Repo Integration](/docs/repo-integration)** — onboarding with classification file
|
||||
@@ -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.
|
||||
|
||||
`/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.*
|
||||
|
||||
@@ -5,18 +5,25 @@ title: Repos — Reference
|
||||
# Repos — Reference
|
||||
|
||||
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?
|
||||
|
||||
A managed repo is a git repository that has been registered with the state hub
|
||||
via `custodian register-project` or `register_repo()`. Registration records the
|
||||
repo's slug, domain, local path, and optional remote URL. Once registered, the
|
||||
repo receives a `CLAUDE.custodian.md` integration suggestion, an onboarding
|
||||
workstream with 4 tasks for the repo agent, and is eligible for SBOM ingestion
|
||||
and the ADR-001 workplan validator.
|
||||
A managed repo is a git repository registered with State Hub. Registration is
|
||||
**classification-driven**:
|
||||
|
||||
1. Commit `.repo-classification.yaml` per the Repo Classification Standard.
|
||||
2. Run `make register-from-classification REPO=<slug>` (or use the MCP tool
|
||||
`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)**.
|
||||
|
||||
@@ -27,69 +34,56 @@ For the full onboarding journey see **[Repo Integration](/docs/repo-integration)
|
||||
| Card | Meaning |
|
||||
|------|---------|
|
||||
| **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 Gaps** | Repos with no ingested SBOM — red border when > 0 |
|
||||
|
||||
---
|
||||
|
||||
## Coverage Map
|
||||
## Portfolio by Category
|
||||
|
||||
Groups repos by domain. Each domain block shows:
|
||||
|
||||
- **Domain name** with SBOM, EP, and TD chip indicators
|
||||
- **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.
|
||||
Groups repos by `category` (experimental, research, project, tooling, product,
|
||||
business). Each block shows domain, capabilities, business stake, and who
|
||||
classified the repo (`human` vs `migration`).
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|--------|--------|
|
||||
| **Domain** | Show repos for a single domain only |
|
||||
| **Gaps only** | Toggle to show only repos without an ingested SBOM |
|
||||
| **Market domain** | Primary domain slug |
|
||||
| **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
|
||||
|
||||
See **[Repo Integration](/docs/repo-integration)** for the full journey.
|
||||
|
||||
Quick reference:
|
||||
Use the **Add Repo** form or:
|
||||
|
||||
```bash
|
||||
# From the repo root — registers, writes CLAUDE.custodian.md, creates onboarding tasks
|
||||
custodian register-project --domain <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.*
|
||||
# 1. Author classification file in the repo
|
||||
# 2. Register / reclassify
|
||||
make register-from-classification PATH=/path/to/repo
|
||||
make fix-consistency REPO=<slug>
|
||||
```
|
||||
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.
|
||||
@@ -265,5 +265,6 @@ why, even years later.
|
||||
- [Connecting to the Hub](/docs/connecting)
|
||||
- [Repo Integration](/docs/repo-integration)
|
||||
- [Repository DoI](/policy/repo-doi) — Definition of Integrated
|
||||
- [Service DoM](/policy/service-dom) — Definition of Mature
|
||||
- [TPSC](/docs/tpsc) — Third-Party Services Catalog
|
||||
- [SBOM](/docs/sbom)
|
||||
|
||||
@@ -7,6 +7,11 @@ title: Third-Party Services Catalog (TPSC)
|
||||
The TPSC tracks external service dependencies (APIs, SaaS, CLIs) across all
|
||||
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?
|
||||
|
||||
@@ -225,7 +225,7 @@ function _workstreamsForMode(mode, rows) {
|
||||
return allRows.filter(w => normalizeWorkstreamStatus(w.status) === modeValue);
|
||||
}
|
||||
if (modeValue === "needs_review") return allRows.filter(needsReviewWorkstream);
|
||||
if (modeValue === "stalled") return allRows.filter(isStalledWorkstream);
|
||||
if (modeValue === "stalled") return allRows.filter(w => isStalledWorkstream(w));
|
||||
|
||||
const since = _timeCutoff(modeValue);
|
||||
if (!since) return allRows.filter(w => normalizeWorkstreamStatus(w.status) === "active");
|
||||
@@ -239,6 +239,16 @@ function _workstreamsForMode(mode, rows) {
|
||||
const _savedChartMode = _MODE_VALUES.has(globalThis.__stateHubOverviewChartMode)
|
||||
? globalThis.__stateHubOverviewChartMode
|
||||
: "active";
|
||||
const _chartModeState = Mutable(_savedChartMode);
|
||||
|
||||
function _setChartMode(value) {
|
||||
const mode = _modeValue(value);
|
||||
globalThis.__stateHubOverviewChartMode = mode;
|
||||
_chartModeState.value = mode;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const _modeSelect = html`<select
|
||||
class="ws-mode-select"
|
||||
aria-label="Workstream chart mode with matching workstream counts"
|
||||
@@ -248,23 +258,21 @@ const _modeSelect = html`<select
|
||||
${group.options.map(([value, label]) => html`<option value=${value}>${label} (${_workstreamsForMode(value, wsAll).length})</option>`)}
|
||||
</optgroup>`)}
|
||||
</select>`;
|
||||
_modeSelect.value = _savedChartMode;
|
||||
_modeSelect.value = _modeValue(_chartModeState);
|
||||
_modeSelect.addEventListener("input", () => {
|
||||
globalThis.__stateHubOverviewChartMode = _modeSelect.value;
|
||||
_setChartMode(_modeSelect.value);
|
||||
});
|
||||
_modeSelect.addEventListener("change", () => {
|
||||
globalThis.__stateHubOverviewChartMode = _modeSelect.value;
|
||||
_setChartMode(_modeSelect.value);
|
||||
});
|
||||
|
||||
// view() is the idiomatic Observable Framework reactive input:
|
||||
// it displays the element AND returns a reactive value that re-runs dependent blocks.
|
||||
const _chartMode = _modeValue(view(_modeSelect));
|
||||
display(_modeSelect);
|
||||
```
|
||||
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const _chartWsFiltered = _workstreamsForMode(_chartMode, wsAll);
|
||||
const _chartModeValue = _modeValue(_chartModeState);
|
||||
const _chartWsFiltered = _workstreamsForMode(_chartModeValue, wsAll);
|
||||
|
||||
// Sort by domain, then repository, then most recently updated workstream.
|
||||
// The axis labels show each domain/repo group once.
|
||||
@@ -278,7 +286,7 @@ const chartWs = [..._chartWsFiltered].sort((a, b) => {
|
||||
|
||||
// ── Status weight: bold for notable statuses in mixed-status modes ─────────────
|
||||
// Color is NOT used for status — avoids green-on-green when finished bars fill the row.
|
||||
const _isTimeBased = !_STATUS_MODES.has(_chartMode) && !_HEALTH_MODES.has(_chartMode);
|
||||
const _isTimeBased = !_STATUS_MODES.has(_chartModeValue) && !_HEALTH_MODES.has(_chartModeValue);
|
||||
function _wsWeight(s) { return (isClosedWorkstream(s) || normalizeWorkstreamStatus(s) === "blocked") ? "bold" : "normal"; }
|
||||
|
||||
// ── y-axis: domain/repo label for first workstream per repository only ────────
|
||||
@@ -330,7 +338,7 @@ if (chartWs.length === 0) {
|
||||
week: "No workstreams changed this week.",
|
||||
month: "No workstreams changed this month.",
|
||||
};
|
||||
display(html`<p style="color:gray">${_emptyMsg[_chartMode] ?? "No workstreams."}</p>`);
|
||||
display(html`<p style="color:gray">${_emptyMsg[_chartModeValue] ?? "No workstreams."}</p>`);
|
||||
} else {
|
||||
display(Plot.plot({
|
||||
y: {
|
||||
|
||||
90
dashboard/src/policy/service-dom.md
Normal file
90
dashboard/src/policy/service-dom.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Service Definition of Mature (DoM)
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
```
|
||||
|
||||
```js
|
||||
import {marked} from "npm:marked";
|
||||
|
||||
const _resp = await fetch(`${API}/policy/service-dom`);
|
||||
if (!_resp.ok) throw new Error(`Failed to load policy: ${_resp.status}`);
|
||||
const _policy = await _resp.json();
|
||||
```
|
||||
|
||||
```js
|
||||
let _content = _policy.content;
|
||||
let _editing = false;
|
||||
|
||||
const _root = display(html`<div></div>`);
|
||||
|
||||
async function _save(text) {
|
||||
const r = await fetch(`${API}/policy/service-dom`, {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({content: text}),
|
||||
});
|
||||
if (!r.ok) throw new Error(`Save failed: ${r.status}`);
|
||||
_content = text;
|
||||
}
|
||||
|
||||
function _toolbar(...nodes) {
|
||||
return html`<div style="display:flex;gap:0.5rem;margin-bottom:1rem">${nodes}</div>`;
|
||||
}
|
||||
|
||||
function _btn(label, primary = false) {
|
||||
return html`<button style="
|
||||
padding:0.35rem 0.9rem;border-radius:4px;cursor:pointer;font-size:13px;
|
||||
background:${primary ? "#1e293b" : "#f1f5f9"};
|
||||
color:${primary ? "#f8fafc" : "#1e293b"};
|
||||
border:1px solid ${primary ? "#1e293b" : "#cbd5e1"};
|
||||
">${label}</button>`;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
_root.innerHTML = "";
|
||||
|
||||
if (_editing) {
|
||||
const area = html`<textarea style="
|
||||
width:100%;box-sizing:border-box;height:520px;
|
||||
font-family:ui-monospace,monospace;font-size:13px;line-height:1.6;
|
||||
padding:0.75rem;border:1px solid #cbd5e1;border-radius:4px;
|
||||
background:#f8fafc;color:#1e293b;resize:vertical;
|
||||
">${_content}</textarea>`;
|
||||
|
||||
const saveBtn = _btn("Save", true);
|
||||
const cancelBtn = _btn("Cancel");
|
||||
|
||||
saveBtn.onclick = async () => {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
try {
|
||||
await _save(area.value);
|
||||
_editing = false;
|
||||
_render();
|
||||
} catch (e) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save";
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => { _editing = false; _render(); };
|
||||
|
||||
_root.append(_toolbar(saveBtn, cancelBtn), area);
|
||||
|
||||
} else {
|
||||
const editBtn = _btn("Edit");
|
||||
editBtn.onclick = () => { _editing = true; _render(); };
|
||||
|
||||
const body = html`<div style="max-width:720px;line-height:1.7;"></div>`;
|
||||
body.innerHTML = marked.parse(_content);
|
||||
|
||||
_root.append(_toolbar(editBtn), body);
|
||||
}
|
||||
}
|
||||
|
||||
_render();
|
||||
```
|
||||
@@ -102,14 +102,28 @@ const repoRows = repos
|
||||
const integrating = !!integratingBySlug[r.slug];
|
||||
const doiEntry = doiBySlug[r.slug] ?? null;
|
||||
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 {
|
||||
_id: r.id,
|
||||
_domSlug: domSlug,
|
||||
_category: category,
|
||||
_capList: capList,
|
||||
_stakeList: stakeList,
|
||||
_hasSbom: hasSbom,
|
||||
_integrating: integrating,
|
||||
_doiTier: doiTier,
|
||||
repo: r.slug,
|
||||
domain: domName,
|
||||
category: category,
|
||||
capTags: capTags,
|
||||
businessStake: stakeList.length ? stakeList.slice(0, 3).join(", ") : "—",
|
||||
classifiedBy: classifiedBy,
|
||||
status: integrating ? "⚙ integrating" : "ready",
|
||||
path: r.local_path ?? "—",
|
||||
sbom: hasSbom ? `✓ ${lastScan}` : "⚠ not ingested",
|
||||
@@ -153,9 +167,13 @@ display(html`<div class="kpi-row">
|
||||
<p class="big-num">${repoRows.length}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Domains</h3>
|
||||
<h3>Market Domains</h3>
|
||||
<p class="big-num">${new Set(repoRows.map(r => r._domSlug)).size}</p>
|
||||
</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' : ''}">
|
||||
<h3>Integrating</h3>
|
||||
<p class="big-num">${integratingCount}</p>
|
||||
@@ -240,6 +258,8 @@ if (domainBlocks.length === 0) {
|
||||
<table class="repo-table">
|
||||
<thead><tr>
|
||||
<th>Repo</th>
|
||||
<th>Category</th>
|
||||
<th>Capabilities</th>
|
||||
<th>DoI Tier</th>
|
||||
<th>Status</th>
|
||||
<th>SBOM</th>
|
||||
@@ -249,6 +269,8 @@ if (domainBlocks.length === 0) {
|
||||
<tbody>
|
||||
${rows.map(r => html`<tr class="${r._integrating ? 'row-integrating' : r._hasSbom ? '' : 'row-gap'}">
|
||||
<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>${r._integrating
|
||||
? 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
|
||||
|
||||
```js
|
||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Domain", 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 style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">${domainFilter}${doiFilter}${gapFilter}</div>`);
|
||||
const domainFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._domSlug)).values()], {label: "Market domain", value: "all"});
|
||||
const categoryFilter = Inputs.select(["all", ...new Set(repoRows.map(r => r._category).filter(c => c !== "—")).values()], {label: "Category", value: "all"});
|
||||
const capabilityFilter = Inputs.select(["all", ...new Set(repoRows.flatMap(r => r._capList)).values()].sort(), {label: "Capability", value: "all"});
|
||||
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
|
||||
const filteredRows = repoRows.filter(r =>
|
||||
(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)
|
||||
);
|
||||
|
||||
display(Inputs.table(filteredRows.map(r => ({
|
||||
Repo: r.repo,
|
||||
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,
|
||||
Status: r.status,
|
||||
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,
|
||||
}));
|
||||
```
|
||||
45
dashboard/test/workplan-status.test.mjs
Normal file
45
dashboard/test/workplan-status.test.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {isStalledWorkstream} from "../src/components/workplan-status.js";
|
||||
|
||||
test("stalled workstream predicate is safe to pass to Array.filter", () => {
|
||||
const realNow = Date.now;
|
||||
Date.now = () => new Date("2026-06-07T15:00:00Z").getTime();
|
||||
|
||||
try {
|
||||
const rows = [
|
||||
{
|
||||
title: "stale active",
|
||||
status: "active",
|
||||
updated_at: "2026-05-20T12:00:00Z",
|
||||
done: 1,
|
||||
progress: 1,
|
||||
todo: 0,
|
||||
wait: 0,
|
||||
},
|
||||
{
|
||||
title: "fresh active",
|
||||
status: "active",
|
||||
updated_at: "2026-06-06T12:00:00Z",
|
||||
done: 1,
|
||||
progress: 1,
|
||||
todo: 0,
|
||||
wait: 0,
|
||||
},
|
||||
{
|
||||
title: "stale finished",
|
||||
status: "finished",
|
||||
updated_at: "2026-05-20T12:00:00Z",
|
||||
done: 2,
|
||||
progress: 0,
|
||||
todo: 0,
|
||||
wait: 0,
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(rows.filter(isStalledWorkstream).map(w => w.title), ["stale active"]);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user