Compare commits

...

46 Commits

Author SHA1 Message Date
f9f0091cfe Fix image workflow: vendor hub-core for legacy DinD docker (no buildx) 2026-07-06 12:53:33 +02:00
ba58365ab7 Fix image workflow: use short SHA for Forgejo archive checkout 2026-07-06 12:49:46 +02:00
d8246577db Fix image workflow: fetch extra repos from main, not primary SHA 2026-07-06 12:48:11 +02:00
817bdde604 Add Forgejo CI smoke and multi-repo image workflow (tier 3 T04) 2026-07-06 12:33:10 +02:00
27db6cff41 Deploy state-hub:f2e042a to coulombcore cluster.
Pin helm handoff and Makefile defaults to the f2e042a image after build,
push, CNPG migration, and helm upgrade. Cluster is primary again via
state-hub-primary tunnel.
2026-07-06 11:08:50 +02:00
f2e042a278 Finish CUST-WP-0011 and implement STATE-WP-0061 suggestion backlog.
Add persisted Suggestion entities with WSJF ranking, relevance bumps,
REST/MCP write surfaces, /suggestions dashboard page, and daily triage
digest integration. Document the cluster operating model, archive the
completed migration workplan, and seed WARDEN-WP-0012 routing scenarios.
2026-07-06 10:52:49 +02:00
cac9a6b1e0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-06:
  - update .custodian-brief.md for state-hub
2026-07-06 10:36:30 +02:00
dbe917ceae Workplan consistency optimization 2026-07-04 00:42:56 +02:00
5388aad77a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-03:
  - update .custodian-brief.md for state-hub
2026-07-03 19:27:09 +02:00
a0167ff386 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-03:
  - update .custodian-brief.md for state-hub
2026-07-03 18:06:57 +02:00
2b6a3ef521 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-03:
  - update .custodian-brief.md for state-hub
2026-07-03 11:22:22 +02:00
8bd4a67639 CUST-WP-0011-T07 done: cluster State Hub is primary (exact-count restore, tunnel rewire)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:20:16 +02:00
ea1fd23481 Workplan terminology: templates, updater guard, add_progress_event alias
- project_rules templates: rename workstream->workplan in prose; registration
  guidance is now file-first + fix-consistency C-06 (manual create_workplan/
  create_workstream calls create duplicates); progress examples use
  workplan_id; legacy field names (state_hub_workstream_id) annotated
- update_agent_instruction_files: never overwrite filled-in
  stack-and-commands/repo-boundary/architecture rules (TODO-marker guard)
- mcp_server: add_progress_event accepts workplan_id (preferred) with
  workstream_id kept as legacy alias, mirroring create_task

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:47:47 +02:00
a361ce8731 chore: add local consistency sync cli 2026-07-02 00:15:16 +02:00
1f61008837 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-01:
  - update .custodian-brief.md for state-hub
2026-07-01 23:50:19 +02:00
d2e5f4c8cc chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-07-01:
  - update .custodian-brief.md for state-hub
2026-07-01 23:47:46 +02:00
24041bc3ef chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-25:
  - update .custodian-brief.md for state-hub
2026-06-25 16:02:15 +02:00
cf00d3bba5 docs(statehub): record railiance data restore 2026-06-25 16:00:39 +02:00
7661146b48 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-25:
  - update .custodian-brief.md for state-hub
2026-06-25 15:40:55 +02:00
8a9bfcc9bd feat(statehub): deploy empty railiance state hub 2026-06-25 15:39:53 +02:00
ec991f4ccd chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-25:
  - update .custodian-brief.md for state-hub
2026-06-25 15:16:51 +02:00
434c80c2c3 feat(statehub): add railiance deployment manifests 2026-06-25 15:15:30 +02:00
6ee5542a88 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-25:
  - update .custodian-brief.md for state-hub
2026-06-25 14:02:54 +02:00
48815b3db9 feat(statehub): publish railiance image 2026-06-25 14:01:10 +02:00
b536741539 feat(statehub): add offline write buffer relay 2026-06-25 13:44:27 +02:00
63f0398304 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for state-hub
2026-06-23 22:13:39 +02:00
c7370c360a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for state-hub
2026-06-23 21:42:18 +02:00
13a331cdf1 Complete State Hub bootstrap workplans (WP-0001)
- Review integration files; fill SCOPE where templated
- Document dev workflow in stack-and-commands.md
- Seed WP-0002 implementation workplan; mark bootstrap finished
- Hub sync via fix-consistency
2026-06-22 23:35:32 +02:00
eebb1b8c29 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 23:27:22 +02:00
020f3c1688 Close STATE-WP-0067 attached-repo agent normalization workplan
Record batch results, mark all tasks done, and set workplan status to finished.
2026-06-22 23:26:48 +02:00
0f3dba6d83 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 23:21:20 +02:00
cfa3241aed Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:28 +02:00
ae2302df64 Fix workplan frontmatter join and prefix inference (STATE-WP-0067)
Repair glued --- delimiters, infer prefixes from frontmatter ids, and
support bare WP-* workplan schemes.
2026-06-22 23:16:15 +02:00
fcb41e8c25 Add STATE-WP-0067 attached-repo agent and workplan normalization
Infer workplan prefixes from on-disk filenames instead of first-token
derivation, add a frontmatter normalization script, and wire Make targets
for dirty-repo sweeps.
2026-06-22 23:15:15 +02:00
e4ab64fa54 Regenerate agent instruction files for dev-hub MCP name
Refresh session-protocol and related rule files after template update.
2026-06-22 21:24:35 +02:00
398f458374 Rename MCP server identifier from state-hub to dev-hub
Introduce canonical MCP_SERVER_NAME constants, shared registration helpers,
and a migrate_mcp_config.py script for ~/.claude.json upgrades. Registration,
patch, and custodian CLI checks accept both dev-hub and legacy state-hub during
transition. API root metadata and session-protocol template reflect the new name.
2026-06-22 20:46:14 +02:00
18a5e2d6f0 fix(health): use injected session instead of global engine pool
The /state/health probe now depends on get_session so pytest's
dependency override routes through the test engine. Using engine.connect()
directly caused asyncpg pool teardown failures (Event loop is closed) late
in the full suite.
2026-06-22 20:19:28 +02:00
262682cdf0 refactor(hub-core): mount capability write router and compose MCP tools
Use create_capability_request_write_router with dev-hub callbacks and attach
generic HubCoreMCPServer tools while keeping enriched local overrides.
2026-06-22 19:52:22 +02:00
c421a2a60d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 17:54:16 +02:00
68e413905b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 16:29:38 +02:00
94c7817339 feat(summary): revision-gated cache with stale-while-revalidate (STATE-WP-0066)
Replace the fixed 15s TTL on GET /state/summary with per-table revision
watermarks, stale-while-revalidate background refresh, and a progress-tail
section split. SQLAlchemy write hooks invalidate core or progress sections
on mutation. Adds tests, benchmark script, and operator docs.
2026-06-22 16:27:32 +02:00
f88e74288d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 16:16:39 +02:00
ffaaf48fcb Add STATE-WP-0066 workplan for state summary revision cache
Defines revision-gated caching, stale-while-revalidate, section split for
recent_progress, mutation invalidation, and bridge-path verification.
2026-06-22 16:15:33 +02:00
0949d4c0d8 feat(classification-spine): implement STATE-WP-0065 repo-anchored model
Replace the ad-hoc coordination-domain spine with the Repo Classification
Standard: 14 market domains, classification columns on managed_repos, and
workplans anchored by repo_id (topic_id optional).

- Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename
- Add api/classification.py validation and register-from-classification tooling
- Expose workplan-first REST/MCP surface with legacy workstream aliases
- Add C-24 consistency rule and legacy domain frontmatter mapping
- Update dashboard repos page with category/capability/stake filters
- Update orientation docs; mark STATE-WP-0065 finished
2026-06-22 13:52:13 +02:00
279be4ffbd chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 13:48:53 +02:00
427e63d9df chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for state-hub
2026-06-22 12:41:26 +02:00
194 changed files with 11737 additions and 1900 deletions

View File

@@ -1,11 +1,11 @@
## First Session Protocol
Triggered when `get_domain_summary("custodian")` shows **no workstreams**.
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/custodian/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/custodian/roadmap_v0.1.md` — planned phases
- `~/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**
@@ -28,7 +28,7 @@ create_task(workstream_id="<id>", title="...", priority="high|medium|low")
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured custodian into N workstreams, M tasks",
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M}

View File

@@ -1,5 +1,5 @@
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
**Domain:** custodian
**Domain:** infotech
**Repo slug:** state-hub
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View File

@@ -1,6 +1,7 @@
## Session Protocol
State Hub: http://127.0.0.1:8000
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
@@ -10,7 +11,7 @@ cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("custodian")
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
@@ -43,7 +44,7 @@ For each file with `status: ready`, `active`, or `blocked`, note pending
**Step 4 — Present brief**
1. **Active workstreams** for `custodian` — title, task counts, blocking decisions
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:state-hub]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*

View File

@@ -1,25 +1,12 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — state-hub
**Domain:** custodian
**Last synced:** 2026-06-22 09:58 UTC
**Domain:** infotech
**Last synced:** 2026-07-06 08:36 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### Repo-anchored classification spine (CUST-WP-0050 implementation)
Progress: 0/8 done | workstream_id: `8dc7d106-11e2-41df-b512-89ed69d2a65f`
**Open tasks:**
- · P1 — Spine migration (single Alembic revision) `14cf65f1`
- · P2 — API / MCP / validation surface `d3afcae1`
- · P3 — Auto-registration tooling `bab90f0c`
- · P4 — Surfaces & cutover `67c54009`
- · P1 — Spine migration (single Alembic revision) `d7112122`
- · P2 — API / MCP / validation surface `715a2ffc`
- · P3 — Auto-registration tooling `029dd0b3`
- … and 1 more open tasks
### State Hub Full ThreePhoenix HA Migration
Progress: 0/8 done | workstream_id: `8d0c1b5d-44da-4b91-8357-e6526d3e0a85`
@@ -33,22 +20,10 @@ Progress: 0/8 done | workstream_id: `8d0c1b5d-44da-4b91-8357-e6526d3e0a85`
- · T07 — Update agent access and runbooks for HA endpoint `959062d8`
- … and 1 more open tasks
### Pragmatic State Hub Migration to railiance01
Progress: 2/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`
---
## 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.

View File

@@ -0,0 +1,29 @@
# Canonical CI smoke template (tier 1 routing drill).
# Copy to: .forgejo/workflows/ci-smoke.yaml in consumer repos.
name: CI Smoke
on:
push:
branches:
- main
workflow_dispatch:
jobs:
host-smoke:
runs-on: self-hosted
steps:
- name: Routing probe (host runner)
run: |
set -eu
echo "repository=${GITHUB_REPOSITORY:-unknown}"
echo "sha=${GITHUB_SHA:-unknown}"
echo "runner=${RUNNER_NAME:-unknown}"
uname -a
container-smoke:
runs-on: ubuntu-latest
steps:
- name: Routing probe (container label)
run: |
set -eu
echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}"

View File

@@ -0,0 +1,69 @@
# Multi-repo Docker build template for tier-3 prep (e.g. state-hub + hub-core).
# Copy to: .forgejo/workflows/image.yaml and set PRIMARY_REPO + EXTRA_REPOS.
# Uses archive checkout (no actions/checkout; non-root runner has no git).
# Dockerfile must reference named contexts, e.g.:
# COPY --from=hub_core_src pyproject.toml /tmp/hub-core/pyproject.toml
name: Build and Publish Multi-Context Image
on:
push:
branches:
- main
paths:
- ".forgejo/workflows/image.yaml"
- "Dockerfile"
workflow_dispatch:
env:
REGISTRY: forgejo.coulomb.social
IMAGE_NAME: coulomb/state-hub
DOCKER_HOST: tcp://127.0.0.1:2375
# Space-separated coulomb/repo@context_name entries for extra build contexts.
# Example: "coulomb/hub-core@hub_core_src"
EXTRA_REPOS: "coulomb/hub-core@hub_core_src"
jobs:
build-and-push:
runs-on: container-build
steps:
- name: Build and push image
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
REF="${GITHUB_SHA:-main}"
SHORT="${REF:0:7}"
mkdir -p buildctx "${HOME}/bin"
# Forgejo archive endpoint accepts short SHA; full SHA can hang.
wget -qO /tmp/primary.tar.gz \
"https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz"
tar xzf /tmp/primary.tar.gz -C buildctx --strip-components=1
BUILD_ARGS=()
for spec in ${EXTRA_REPOS}; do
repo="${spec%@*}"
ctx="${spec#*@}"
# Extra repos track their own main branch; primary SHA is not valid there.
extra_ref="main"
wget -qO "/tmp/${ctx}.tar.gz" \
"https://forgejo.coulomb.social/${repo}/archive/${extra_ref}.tar.gz"
mkdir -p "/tmp/ctx-${ctx}"
tar xzf "/tmp/${ctx}.tar.gz" -C "/tmp/ctx-${ctx}" --strip-components=1
done
# Runner DinD has legacy docker (no buildx); vendor hub-core into context.
mkdir -p buildctx/_hub_core_src
cp -r /tmp/ctx-hub_core_src/pyproject.toml /tmp/ctx-hub_core_src/hub_core buildctx/_hub_core_src/
sed \
-e 's|COPY --from=hub_core_src pyproject.toml|COPY _hub_core_src/pyproject.toml|' \
-e 's|COPY --from=hub_core_src hub_core/|COPY _hub_core_src/hub_core/|' \
buildctx/Dockerfile > buildctx/Dockerfile.ci
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
| tar xz --strip-components=1 -C "${HOME}/bin" docker/docker
export PATH="${HOME}/bin:${PATH}"
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker build -f buildctx/Dockerfile.ci \
-t "${IMAGE}:latest" -t "${IMAGE}:main-${SHORT}" buildctx
docker push "${IMAGE}:latest"
docker push "${IMAGE}:main-${SHORT}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:main-${SHORT}"

View File

@@ -4,12 +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.
**Periodic consistency sync:** The 15-minute workplan↔DB sweep is scheduled on
activity-core (Railiance01), not a local timer. Execution still runs on this
workstation via the bridge tunnel. Runbook:
[`docs/consistency-sweep-runbook.md`](docs/consistency-sweep-runbook.md).
**Domain:** custodian
**Domain:** infotech
**Repo slug:** state-hub
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `STATE-WP-`
@@ -23,8 +18,11 @@ there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
| Primary (cluster via tunnel) | `http://127.0.0.1:8000` |
| Remote machine mesh | `http://127.0.0.1:18000` |
Cluster operating model (access, rollback, backups):
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md)
### Orient at session start
@@ -32,8 +30,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
@@ -85,7 +83,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`
@@ -97,12 +95,12 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
3. After workplan file changes, run:
```bash
make fix-consistency REPO=state-hub
statehub fix-consistency
```
This syncs task status from files into the hub DB.
Coding agents should run this directly; ask the operator only if the CLI or
State Hub API is unavailable. This syncs task status from files into the hub DB.
---
@@ -156,6 +154,11 @@ every repo's agent instructions because it is high-frequency, high-risk, and eas
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)
@@ -181,7 +184,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
@@ -192,10 +195,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.
@@ -219,5 +218,5 @@ Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blo
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=state-hub`
(or send a message to the hub agent via `POST /messages/`)
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
State Hub API is unavailable.

View File

@@ -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/

View File

@@ -42,6 +42,11 @@ general application platform. When State Hub detects facts, it records and
exposes them; when new work must be spawned from events, that responsibility
belongs to activity-core or a human-approved workflow.
**Sanctioned writes** (STATE-WP-0061): besides `resolve_decision` and progress
events, the hub may persist gated needs as `Suggestion` records, bump demand
`relevance`, vet/decline/promote them into real tasks, and project WSJF ranking
as a read model for daily triage.
---
## What it is

View File

@@ -1,7 +1,17 @@
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
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 ?= f2e042a
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)
@@ -61,12 +71,59 @@ 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
## 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
tunnels-up:
@@ -249,6 +306,25 @@ fix-consistency:
$(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:
@@ -322,5 +398,20 @@ remove-hooks:
gitea-inventory:
$(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

View File

@@ -50,6 +50,11 @@ then run consistency sync.
All services bind to `127.0.0.1` only — nothing exposed to the network.
**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation
port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access,
rollback, backups, and pragmatic limitations.
---
## Setup
@@ -111,7 +116,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 +132,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 +190,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 +243,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`.
---

View File

@@ -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,9 +12,11 @@ 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
- SBOM, contribution, capability, demand-weighted suggestion backlog, TPSC, DoI,
token, and interface-change tracking
- State Hub tests, operational docs, policies, prompts, and local infra
## Out Of Scope

290
api/classification.py Normal file
View 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
View File

@@ -0,0 +1 @@
"""State Hub edge relay and durable outbox helpers."""

358
api/edge/outbox.py Normal file
View 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
View 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()

View File

@@ -11,7 +11,8 @@ from starlette.responses import Response as StarletteResponse
from api.database import engine
from api.events import shutdown_publisher
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.services.write_idempotency import WriteIdempotencyMiddleware
from api.routers import decisions, extension_points, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
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
@@ -91,13 +92,14 @@ _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)
@@ -121,6 +123,7 @@ app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(messages.router)
app.include_router(capability_requests.router)
app.include_router(suggestions.router)
app.include_router(tpsc.router)
app.include_router(services.router)
app.include_router(token_events.router)
@@ -136,4 +139,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"}

View File

@@ -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
@@ -31,6 +33,13 @@ 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
from api.models.suggestion import (
Suggestion,
SuggestionNote,
SuggestionRelevanceBump,
SuggestionStage,
)
__all__ = [
"Base",
@@ -39,6 +48,8 @@ __all__ = [
"Topic", "TopicStatus",
"ManagedRepo",
"RepoGoal", "RepoGoalStatus",
"Workplan",
"WorkplanDependency",
"Workstream",
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
@@ -61,4 +72,6 @@ __all__ = [
"WorkplanLaunchRequest",
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket",
]
"WriteIdempotencyKey",
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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:

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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

120
api/models/suggestion.py Normal file
View File

@@ -0,0 +1,120 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from api.models.base import Base, TimestampMixin, new_uuid
class SuggestionStage(str, enum.Enum):
suggestion = "suggestion"
requirement = "requirement"
promoted = "promoted"
declined = "declined"
OPEN_SUGGESTION_STAGES = (SuggestionStage.suggestion, SuggestionStage.requirement)
class Suggestion(Base, TimestampMixin):
__tablename__ = "suggestions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("domains.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.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
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
stage: Mapped[SuggestionStage] = mapped_column(
Enum(SuggestionStage, name="suggestionstage"),
nullable=False,
default=SuggestionStage.suggestion,
index=True,
)
relevance: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
relevance_events: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
last_requested_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
base_value: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
job_size: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
relevance_weight: Mapped[float] = mapped_column(
Float, nullable=False, default=1.0, server_default="1"
)
promoted_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
promoted_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
notes: Mapped[list["SuggestionNote"]] = relationship(
"SuggestionNote",
back_populates="suggestion",
lazy="selectin",
order_by="SuggestionNote.created_at",
)
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""
class SuggestionNote(Base):
__tablename__ = "suggestion_notes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
suggestion_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("suggestions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
stage: Mapped[str] = mapped_column(String(30), nullable=False)
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
suggestion: Mapped["Suggestion"] = relationship("Suggestion", back_populates="notes")
class SuggestionRelevanceBump(Base):
"""Audit trail for relevance bumps; supports debounce lookups."""
__tablename__ = "suggestion_relevance_bumps"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
suggestion_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("suggestions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
source: Mapped[str] = mapped_column(String(50), nullable=False)
source_key: Mapped[str] = mapped_column(String(200), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@@ -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"
)

View File

@@ -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",

View File

@@ -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

View File

@@ -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"

76
api/models/workplan.py Normal file
View File

@@ -0,0 +1,76 @@
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,
)
backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True)
backing_archived: Mapped[bool | None] = mapped_column(nullable=True)
backing_synced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=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"
)

View 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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"]

View 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)

View File

@@ -1,8 +1,7 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +13,7 @@ from api.models.capability_request import CapabilityRequest
from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.services.suggestion_relevance import bump_matching_for_capability_request
from api.schemas.capability_request import (
CapabilityRequestAccept,
CapabilityRequestCreate,
@@ -26,60 +26,75 @@ from api.schemas.capability_request import (
from hub_core.routers.capabilities import (
create_capability_catalog_router,
create_capability_request_read_router,
)
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
)
router.include_router(
create_capability_request_read_router(
get_session,
domain_model=Domain,
request_model=CapabilityRequest,
request_read_schema=CapabilityRequestRead,
)
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 _on_capability_request_persisted(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestCreate,
) -> None:
await session.flush()
await bump_matching_for_capability_request(
session,
request_id=req.id,
title=body.title,
description=body.description or "",
capability_type=body.capability_type,
catalog_entry_id=req.catalog_entry_id,
)
await _notify_on_create(session, req, body)
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"
@@ -98,29 +113,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.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,
@@ -129,30 +131,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":
@@ -200,23 +186,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:
@@ -225,8 +200,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}); "
@@ -241,49 +214,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,
@@ -297,7 +251,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,
@@ -312,52 +265,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,
@@ -368,7 +282,6 @@ async def reroute_request(
f"**Note:** {body.note}"
),
)
# Notify new fulfilling domain
_add_notification(
session,
from_agent=body.rerouted_by,
@@ -383,24 +296,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,
@@ -412,20 +321,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:
@@ -456,7 +364,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,
@@ -466,21 +373,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 _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",
@@ -500,3 +392,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=_on_capability_request_persisted,
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,
)
)

View File

@@ -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,
)

View File

@@ -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],
},

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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")

View File

@@ -9,9 +9,10 @@ import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import case, func, select
from fastapi import APIRouter, Depends, HTTPException, Response, status
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
@@ -29,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,
@@ -44,6 +45,8 @@ 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
@@ -76,13 +79,107 @@ def _core_repo_router(**route_flags) -> APIRouter:
repo_read_schema=RepoRead,
repo_path_register_schema=RepoPathRegister,
list_noload_fields=("goals",),
create_extension_fields=("topic_id",),
create_extension_fields=(
"topic_id",
"category",
"secondary_domains",
"capability_tags",
"business_stake",
"business_mechanics",
"classified_at",
"classified_by",
"standard_version",
),
after_register=_publish_repo_registered,
**route_flags,
)
router.include_router(_core_repo_router(include_slug_routes=False))
router.include_router(
_core_repo_router(include_collection_routes=False, include_slug_routes=False)
)
@router.get("/", response_model=list[RepoRead])
async def list_repos(
response: Response,
domain: str | None = None,
category: str | None = None,
capability_tag: str | None = None,
business_stake: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ManagedRepo]:
"""List repos with optional domain and classification filters."""
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
q = (
select(ManagedRepo)
.options(noload(ManagedRepo.goals))
.order_by(ManagedRepo.name)
)
if domain:
domain_result = await session.execute(select(Domain).where(Domain.slug == domain))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
q = q.where(
or_(
ManagedRepo.domain_id == domain_obj.id,
ManagedRepo.secondary_domains.contains([domain]),
)
)
if category:
q = q.where(ManagedRepo.category == category)
if capability_tag:
q = q.where(ManagedRepo.capability_tags.contains([capability_tag]))
if business_stake:
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
result = await session.execute(q)
return list(result.scalars().all())
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
async def register_repo(
body: RepoCreate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
domain_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
domain_obj = domain_result.scalar_one_or_none()
if domain_obj is None:
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
payload = body.model_dump()
validate_repo_classification_fields(
domain_slug=body.domain_slug,
fields=payload,
require_complete=classification_fields_set(payload),
)
repo = ManagedRepo(
domain_id=domain_obj.id,
slug=body.slug,
name=body.name,
local_path=body.local_path,
host_paths=body.host_paths,
remote_url=body.remote_url,
git_fingerprint=body.git_fingerprint,
description=body.description,
topic_id=body.topic_id,
category=body.category,
secondary_domains=body.secondary_domains,
capability_tags=body.capability_tags,
business_stake=body.business_stake,
business_mechanics=body.business_mechanics,
classified_at=body.classified_at,
classified_by=body.classified_by,
standard_version=body.standard_version,
)
session.add(repo)
await session.commit()
await session.refresh(repo)
await _publish_repo_registered(repo, body, domain_obj)
return repo
@router.post("/onboard", response_model=RepoOnboardResult)
@@ -428,6 +525,38 @@ async def list_repo_scope_health(
return entries
@router.patch("/{slug}", response_model=RepoRead)
async def update_repo_with_classification(
slug: str,
body: RepoUpdate,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
"""Patch repo metadata including classification spine fields."""
repo = await _get_repo_by_slug(slug, session)
payload = body.model_dump(exclude_unset=True)
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
domain_obj = domain_result.scalar_one_or_none()
domain_slug = domain_obj.slug if domain_obj else ""
if classification_fields_set(payload):
merged = {
"category": payload.get("category", repo.category),
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
"capability_tags": payload.get("capability_tags", repo.capability_tags),
"business_stake": payload.get("business_stake", repo.business_stake),
"business_mechanics": payload.get("business_mechanics", repo.business_mechanics),
}
validate_repo_classification_fields(
domain_slug=domain_slug,
fields=merged,
require_complete=True,
)
for field, value in payload.items():
setattr(repo, field, value)
await session.commit()
await session.refresh(repo)
return repo
router.include_router(
_core_repo_router(
include_collection_routes=False,
@@ -480,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())
@@ -511,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,
@@ -554,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,

View File

@@ -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,12 @@ 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.suggestion import OPEN_SUGGESTION_STAGES, Suggestion
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.suggestion import RankedSuggestionDigest
from api.services.suggestion_relevance import bump_suggestions_for_next_steps
from api.services.suggestion_wsjf import compute_wsjf, suggestion_sort_key
from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
@@ -43,38 +47,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 +122,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 +136,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 +176,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 +187,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 +197,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 +208,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 +229,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 +270,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 +286,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)
@@ -293,7 +333,7 @@ async def get_summary(
),
)
next_steps = await _derive_next_steps(session)
next_steps, _ = await _derive_next_steps(session)
# Domain summary stats
domain_summaries = await _build_domain_summaries(session)
@@ -332,6 +372,8 @@ async def get_summary(
)
)).scalar() or 0
ranked_suggestions = await _ranked_suggestion_digest(session, limit=10)
result = StateSummary(
generated_at=datetime.now(tz=timezone.utc),
totals=totals,
@@ -351,6 +393,7 @@ async def get_summary(
contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
ranked_suggestions=ranked_suggestions,
open_workstreams=[
WorkstreamWithDeps(
**{
@@ -370,11 +413,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 +452,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 +463,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 +500,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 +512,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 +535,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 +676,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"),
@@ -662,7 +707,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
waiting_tasks=[TaskRead.model_validate(t) for t in waiting],
blocked_tasks=[TaskRead.model_validate(t) for t in waiting],
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
next_steps=await _derive_next_steps(session),
next_steps=(await _derive_next_steps(session))[0],
contribution_counts=contribution_counts,
licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count,
@@ -695,9 +740,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 +779,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 +790,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 +800,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 +810,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 +822,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,
@@ -814,7 +859,33 @@ _PRIORITY_RANK = {
}
async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
async def _ranked_suggestion_digest(
session: AsyncSession,
*,
limit: int = 10,
) -> list[RankedSuggestionDigest]:
rows = (
await session.execute(
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
)
).scalars().all()
ranked = sorted(rows, key=suggestion_sort_key)[:limit]
return [
RankedSuggestionDigest(
id=s.id,
title=s.title,
stage=s.stage,
domain_slug=s.domain_slug,
origin_ref=s.origin_ref,
relevance=s.relevance,
wsjf=compute_wsjf(s),
last_requested_at=s.last_requested_at,
)
for s in ranked
]
async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], list[Suggestion]]:
"""Derive contextual next-action suggestions from current hub state.
Two signal sources:
@@ -831,7 +902,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 +910,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 +919,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 +939,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 +957,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 +977,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 +989,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, {})
@@ -953,10 +1024,34 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
))
seen_task_ids.add(task.id)
return steps
# Signal 3: open demand-weighted suggestions (needed-but-unmet backlog)
open_suggestions = sorted(
(
await session.execute(
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
)
).scalars().all(),
key=suggestion_sort_key,
)[:5]
for suggestion in open_suggestions:
steps.append(NextStep(
type="open_suggestion",
domain=suggestion.domain_slug,
workstream_id=suggestion.workplan_id,
workstream_title=None,
workstream_slug=suggestion.origin_ref,
task_id=None,
task_title=None,
message=(
f"Gated need '{suggestion.title}' (stage={suggestion.stage.value}, "
f"relevance={suggestion.relevance}) — vet or promote when unblocked"
),
))
return steps, open_suggestions
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
@@ -981,15 +1076,18 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
Returns suggestions based on:
- Recently resolved decisions → first open task in the same workstream
- Workstreams whose every dependency workstream is now finished -> first todo task
- Open demand-weighted suggestions (gated needs accruing relevance)
"""
return await _derive_next_steps(session)
steps, surfaced = await _derive_next_steps(session)
await bump_suggestions_for_next_steps(session, surfaced)
await session.commit()
return steps
@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(

259
api/routers/suggestions.py Normal file
View File

@@ -0,0 +1,259 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.domain import Domain
from api.models.suggestion import (
OPEN_SUGGESTION_STAGES,
Suggestion,
SuggestionNote,
SuggestionStage,
)
from api.models.task import Task, TaskPriority, TaskStatus
from api.schemas.suggestion import (
SuggestionBumpRelevance,
SuggestionCreate,
SuggestionDecline,
SuggestionNoteRead,
SuggestionPromote,
SuggestionRead,
SuggestionVet,
)
from api.services.suggestion_relevance import bump_relevance
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
from api.task_status import normalize_task_status
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
row = await session.execute(
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
)
domain_id = row.scalar_one_or_none()
if domain_id is None:
valid = [r[0] for r in (await session.execute(
select(Domain.slug).where(Domain.status == "active")
)).all()]
raise HTTPException(
status_code=422,
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
)
return domain_id
def _enrich_read(suggestion: Suggestion) -> SuggestionRead:
data = SuggestionRead.model_validate(suggestion)
data.cost_of_delay = cost_of_delay(suggestion)
data.wsjf = compute_wsjf(suggestion)
return data
async def _get_suggestion_or_404(
suggestion_id: uuid.UUID,
session: AsyncSession,
) -> Suggestion:
suggestion = await session.get(Suggestion, suggestion_id)
if suggestion is None:
raise HTTPException(status_code=404, detail="Suggestion not found")
return suggestion
def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None:
if suggestion.stage not in allowed:
raise HTTPException(
status_code=409,
detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'",
)
@router.get("/", response_model=list[SuggestionRead])
async def list_suggestions(
domain: str | None = None,
stage: SuggestionStage | None = None,
include_terminal: bool = Query(False),
rank: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_session),
) -> list[SuggestionRead]:
q = select(Suggestion)
if domain:
domain_id = await _resolve_domain_id(domain, session)
q = q.where(Suggestion.domain_id == domain_id)
if stage:
q = q.where(Suggestion.stage == stage)
elif not include_terminal:
q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
result = await session.execute(q)
suggestions = list(result.scalars().all())
if rank == "wsjf":
suggestions.sort(key=suggestion_sort_key)
else:
suggestions.sort(key=lambda s: s.created_at)
return [_enrich_read(s) for s in suggestions[:limit]]
@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED)
async def create_suggestion(
body: SuggestionCreate,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
domain_id = await _resolve_domain_id(body.domain, session)
suggestion = Suggestion(
domain_id=domain_id,
topic_id=body.topic_id,
workplan_id=body.workplan_id,
title=body.title,
description=body.description,
origin=body.origin,
origin_ref=body.origin_ref,
base_value=body.base_value,
job_size=body.job_size,
relevance_weight=body.relevance_weight,
)
session.add(suggestion)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.get("/{suggestion_id}", response_model=SuggestionRead)
async def get_suggestion(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/vet", response_model=SuggestionRead)
async def vet_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionVet,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
_reject_stage(suggestion, _ALLOWED_VET_FROM, "vet")
suggestion.stage = SuggestionStage.requirement
if body.base_value is not None:
suggestion.base_value = body.base_value
if body.job_size is not None:
suggestion.job_size = body.job_size
if body.relevance_weight is not None:
suggestion.relevance_weight = body.relevance_weight
if body.workplan_id is not None:
suggestion.workplan_id = body.workplan_id
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.requirement.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
async def decline_suggestion(
suggestion_id: uuid.UUID,
body: SuggestionDecline,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
_reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline")
suggestion.stage = SuggestionStage.declined
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.declined.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
async def promote_suggestion_to_task(
suggestion_id: uuid.UUID,
body: SuggestionPromote,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
_reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote")
if suggestion.workplan_id is None:
raise HTTPException(
status_code=409,
detail="Suggestion must have workplan_id before promotion",
)
task = Task(
workplan_id=suggestion.workplan_id,
title=body.task_title or suggestion.title,
description=body.task_description or suggestion.description,
status=TaskStatus(normalize_task_status(body.task_status)),
priority=TaskPriority(body.task_priority),
)
session.add(task)
await session.flush()
suggestion.stage = SuggestionStage.promoted
suggestion.promoted_task_id = task.id
if body.note:
session.add(
SuggestionNote(
suggestion_id=suggestion.id,
stage=SuggestionStage.promoted.value,
author=body.author,
content=body.note,
)
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
async def bump_suggestion_relevance(
suggestion_id: uuid.UUID,
body: SuggestionBumpRelevance,
session: AsyncSession = Depends(get_session),
) -> SuggestionRead:
suggestion = await _get_suggestion_or_404(suggestion_id, session)
source_key = body.author or "explicit"
await bump_relevance(
session,
suggestion,
source="explicit",
source_key=source_key,
reason=body.reason,
)
await session.commit()
await session.refresh(suggestion)
return _enrich_read(suggestion)
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])
async def list_suggestion_notes(
suggestion_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> list[SuggestionNote]:
await _get_suggestion_or_404(suggestion_id, session)
result = await session.execute(
select(SuggestionNote)
.where(SuggestionNote.suggestion_id == suggestion_id)
.order_by(SuggestionNote.created_at)
)
return list(result.scalars().all())

View File

@@ -9,7 +9,7 @@ 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.models.workplan import Workplan
from api.schemas.task import (
TaskCountRead,
TaskCreate,
@@ -26,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,
@@ -37,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:
@@ -60,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
]
@@ -84,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,
@@ -137,7 +141,7 @@ async def bulk_status_sync(
target_status = status_value(update.status)
if update.blocking_reason is not None:
task.blocking_reason = update.blocking_reason
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
update.status,
@@ -146,7 +150,7 @@ async def bulk_status_sync(
)
event = ProgressEvent(
task_id=task.id,
workstream_id=task.workstream_id,
workplan_id=task.workplan_id,
event_type="task_status_changed",
summary=f"Task status -> {target_status}: {task.title}",
author=author,
@@ -218,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,
@@ -247,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
@@ -273,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,

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -15,21 +15,22 @@ 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 (
WorkplanBindingsSync,
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 +139,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 +148,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 +191,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 +204,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,
@@ -212,9 +213,38 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
"needs_review": bool(review and review.needs_review),
"health_labels": ["needs_review"] if review and review.needs_review else [],
}
await _merge_db_backing_index(session, index)
return {"workplans": index, "workstreams": index}
async def _merge_db_backing_index(session: AsyncSession, index: dict[str, Any]) -> None:
"""Fill index gaps from DB-backed file bindings synced by fix-consistency."""
result = await session.execute(
select(Workplan, ManagedRepo.slug)
.join(ManagedRepo, Workplan.repo_id == ManagedRepo.id)
.where(Workplan.backing_filename.isnot(None))
)
for wp, repo_slug in result.all():
key = str(wp.id)
if key in index:
continue
index[key] = {
"filename": wp.backing_filename,
"relative_path": wp.backing_relative_path,
"repo_slug": repo_slug,
"archived": bool(wp.backing_archived),
"status": normalize_workplan_status(wp.status) if wp.status else None,
"needs_review": False,
"health_labels": [],
}
def _invalidate_workplan_index_cache() -> None:
global _INDEX_CACHE, _INDEX_CACHE_AT
_INDEX_CACHE = None
_INDEX_CACHE_AT = 0.0
def _index_with_meta(*, stale: bool, refresh_in_progress: bool) -> dict[str, Any]:
age = time.monotonic() - _INDEX_CACHE_AT if _INDEX_CACHE_AT else None
return {
@@ -287,79 +317,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 +402,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 +424,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 +432,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 +443,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 +452,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 +489,35 @@ async def workplan_index_preferred(
return await _workplan_index(refresh=refresh, session=session)
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
@workplan_router.put("/index/bindings")
async def sync_workplan_bindings(
body: WorkplanBindingsSync,
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
"""Upsert workstation workplan file bindings for remote API index fallback."""
synced_at = datetime.now(timezone.utc)
updated = 0
for entry in body.bindings:
wp = await session.get(Workplan, entry.workplan_id)
if wp is None:
continue
wp.backing_filename = entry.filename
wp.backing_relative_path = entry.relative_path
wp.backing_archived = entry.archived
wp.backing_synced_at = synced_at
updated += 1
await session.commit()
_invalidate_workplan_index_cache()
return {"updated": updated, "received": len(body.bindings)}
@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 +525,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 +550,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 +576,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 +602,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)

View File

@@ -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",

View File

@@ -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 hub_core.schemas.capability import (
CapabilityRequestDispute,
@@ -23,20 +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
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
fulfilling_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("fulfilling_workplan_id", "fulfilling_workstream_id"),
)
class CapabilityRequestReroute(BaseModel):
@@ -57,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
@@ -73,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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -2,8 +2,10 @@ import uuid
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from fastapi import HTTPException
from pydantic import BaseModel, Field, model_validator
from api.classification import validate_classification
from hub_core.schemas.managed_repo import (
RepoCreate as CoreRepoCreate,
RepoPathRegister,
@@ -11,11 +13,73 @@ from hub_core.schemas.managed_repo import (
)
class RepoCreate(CoreRepoCreate):
class ClassificationFields(BaseModel):
category: str | None = None
secondary_domains: list[str] | None = None
capability_tags: list[str] | None = None
business_stake: list[str] | None = None
business_mechanics: list[str] | None = None
classified_at: date | None = None
classified_by: str | None = None
standard_version: str | None = None
def classification_fields_set(data: dict[str, Any]) -> bool:
keys = (
"category",
"secondary_domains",
"capability_tags",
"business_stake",
"business_mechanics",
"classified_at",
"classified_by",
"standard_version",
)
return any(data.get(key) is not None for key in keys)
def validate_repo_classification_fields(
*,
domain_slug: str,
fields: dict[str, Any],
require_complete: bool = False,
) -> dict[str, Any]:
"""Validate classification fields and return normalized values for persistence."""
if not classification_fields_set(fields) and not require_complete:
return fields
block = {
"category": fields.get("category"),
"domain": domain_slug,
"secondary_domains": fields.get("secondary_domains") or [],
"capability_tags": fields.get("capability_tags") or [],
"business_stake": fields.get("business_stake") or [],
"business_mechanics": fields.get("business_mechanics") or [],
}
if require_complete or fields.get("category") is not None:
if block["category"] is None:
raise HTTPException(status_code=422, detail="`category` is required when classification is provided")
if classification_fields_set(fields) and block["category"] is not None:
errors, warnings = validate_classification(block)
if errors:
raise HTTPException(status_code=422, detail={"classification_errors": errors, "warnings": warnings})
return fields
class RepoCreate(CoreRepoCreate, ClassificationFields):
topic_id: uuid.UUID | None = None
@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
@@ -42,7 +106,7 @@ class RepoOnboardResult(BaseModel):
stderr: str = ""
class RepoRead(CoreRepoRead):
class RepoRead(CoreRepoRead, ClassificationFields):
topic_id: uuid.UUID | None = None
sbom_source: str | None = None
last_sbom_at: datetime | None = None
@@ -59,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
@@ -90,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
@@ -104,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]

View File

@@ -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

View File

@@ -9,6 +9,7 @@ from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.suggestion import RankedSuggestionDigest
from api.schemas.workstream import WorkstreamWithDeps
@@ -85,6 +86,7 @@ class StateSummary(BaseModel):
contribution_counts: dict[str, int] = {}
licence_risk_count: int = 0
open_capability_requests: int = 0
ranked_suggestions: list[RankedSuggestionDigest] = []
class DashboardWorkplanRow(BaseModel):

96
api/schemas/suggestion.py Normal file
View File

@@ -0,0 +1,96 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from api.models.suggestion import SuggestionStage
class SuggestionNoteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
suggestion_id: uuid.UUID
stage: str
author: str | None = None
content: str
created_at: datetime
class SuggestionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
domain_id: uuid.UUID
domain_slug: str = ""
topic_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
title: str
description: str | None = None
origin: str | None = None
origin_ref: str | None = None
stage: SuggestionStage
relevance: int
relevance_events: int
last_requested_at: datetime | None = None
base_value: float
job_size: float
relevance_weight: float
promoted_task_id: uuid.UUID | None = None
cost_of_delay: float | None = None
wsjf: float | None = None
created_at: datetime
updated_at: datetime
notes: list[SuggestionNoteRead] = Field(default_factory=list)
class SuggestionCreate(BaseModel):
domain: str
title: str
description: str | None = None
topic_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
origin: str | None = None
origin_ref: str | None = None
base_value: float = 3.0
job_size: float = 3.0
relevance_weight: float = 1.0
class SuggestionVet(BaseModel):
author: str | None = None
note: str
base_value: float | None = None
job_size: float | None = None
relevance_weight: float | None = None
workplan_id: uuid.UUID | None = None
class SuggestionDecline(BaseModel):
author: str | None = None
note: str
class SuggestionPromote(BaseModel):
author: str | None = None
note: str | None = None
task_title: str | None = None
task_description: str | None = None
task_priority: str = "medium"
task_status: str = "wait"
class SuggestionBumpRelevance(BaseModel):
reason: str | None = None
author: str | None = None
class RankedSuggestionDigest(BaseModel):
id: uuid.UUID
title: str
stage: SuggestionStage
domain_slug: str
origin_ref: str | None = None
relevance: int
wsjf: float
last_requested_at: datetime | None = None

View File

@@ -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
@@ -96,10 +96,9 @@ class TaskStatusBulkSync(BaseModel):
return value
class TaskRead(TaskStatusMixin):
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
@@ -114,8 +113,7 @@ class TaskRead(TaskStatusMixin):
updated_at: datetime
class TaskCountRead(TaskStatusMixin):
workstream_id: uuid.UUID
class TaskCountRead(TaskStatusMixin, WorkplanIdCompatMixin):
status: TaskStatus
count: int

View File

@@ -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] = []

View File

@@ -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

124
api/schemas/workplan.py Normal file
View File

@@ -0,0 +1,124 @@
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 WorkplanFileBinding(BaseModel):
workplan_id: uuid.UUID
filename: str
relative_path: str
repo_slug: str
archived: bool = False
status: WorkplanStatus | None = None
class WorkplanBindingsSync(BaseModel):
bindings: list[WorkplanFileBinding]
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
backing_filename: str | None = None
backing_relative_path: str | None = None
backing_archived: bool | None = None
backing_synced_at: 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] = []

View 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

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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()

View File

@@ -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,
)
)

View File

@@ -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),

View File

@@ -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,

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.suggestion import (
OPEN_SUGGESTION_STAGES,
Suggestion,
SuggestionRelevanceBump,
SuggestionStage,
)
DEBOUNCE_SECONDS = 3600
async def bump_relevance(
session: AsyncSession,
suggestion: Suggestion,
*,
source: str,
source_key: str,
reason: str | None = None,
) -> bool:
"""Increment relevance when a suggestion is needed-but-unmet.
Returns True when the counter was incremented, False when debounced.
"""
if suggestion.stage not in OPEN_SUGGESTION_STAGES:
return False
cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=DEBOUNCE_SECONDS)
existing = (
await session.execute(
select(SuggestionRelevanceBump.id)
.where(SuggestionRelevanceBump.suggestion_id == suggestion.id)
.where(SuggestionRelevanceBump.source == source)
.where(SuggestionRelevanceBump.source_key == source_key)
.where(SuggestionRelevanceBump.created_at >= cutoff)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return False
now = datetime.now(tz=timezone.utc)
suggestion.relevance += 1
suggestion.relevance_events += 1
suggestion.last_requested_at = now
session.add(
SuggestionRelevanceBump(
suggestion_id=suggestion.id,
source=source,
source_key=source_key,
reason=reason,
)
)
return True
async def bump_matching_for_capability_request(
session: AsyncSession,
*,
request_id: uuid.UUID,
title: str,
description: str,
capability_type: str,
catalog_entry_id: uuid.UUID | None = None,
) -> int:
"""Bump open suggestions that match an unfulfilled capability need."""
rows = (
await session.execute(
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
)
).scalars().all()
haystack = f"{title} {description} {capability_type}".lower()
bumped = 0
for suggestion in rows:
matched = False
if suggestion.origin_ref:
ref = suggestion.origin_ref.lower()
if ref in haystack or any(token in haystack for token in ref.split("-") if len(token) > 4):
matched = True
if not matched and suggestion.title.lower() in haystack:
matched = True
if not matched:
continue
if await bump_relevance(
session,
suggestion,
source="capability_request",
source_key=str(request_id),
reason=f"Capability request matched: {title}",
):
bumped += 1
return bumped
async def bump_suggestions_for_next_steps(
session: AsyncSession,
suggestions: list[Suggestion],
) -> int:
"""Bump only suggestions surfaced in a next-steps response."""
bucket = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H")
bumped = 0
for suggestion in suggestions:
if await bump_relevance(
session,
suggestion,
source="next_steps",
source_key=bucket,
reason="Surfaced during get_next_steps lookup",
):
bumped += 1
return bumped

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from api.models.suggestion import Suggestion
def cost_of_delay(suggestion: Suggestion) -> float:
return suggestion.base_value + (suggestion.relevance_weight * suggestion.relevance)
def compute_wsjf(suggestion: Suggestion) -> float:
size = suggestion.job_size if suggestion.job_size > 0 else 3.0
return round(cost_of_delay(suggestion) / size, 2)
def suggestion_sort_key(suggestion: Suggestion) -> tuple[float, int, str]:
"""Higher WSJF first; tie-break on relevance then title."""
return (-compute_wsjf(suggestion), -suggestion.relevance, suggestion.title.lower())

View 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

View File

@@ -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

View File

@@ -0,0 +1,226 @@
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"),
WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"),
WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"),
WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"),
)
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

View File

@@ -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,

View File

@@ -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 ─────────────────────────────────────
@@ -366,6 +365,57 @@ def cmd_ingest_sbom(args: argparse.Namespace) -> None:
sys.exit(result.returncode)
def cmd_fix_consistency(args: argparse.Namespace) -> None:
"""Run ADR-001 consistency repair from any registered repo checkout."""
checker = STATE_HUB_DIR / "scripts" / "consistency_check.py"
if not checker.exists():
print(f"ERROR: consistency checker not found at {checker}")
print(" Run this command from an editable state-hub install or the state-hub repo.")
sys.exit(1)
if args.remote and not (args.repo or args.all):
print("ERROR: --remote requires --repo or --all.")
print(" From a local checkout, run: statehub fix-consistency")
print(" For pull-before-fix, run: statehub fix-consistency --repo <slug> --remote")
sys.exit(1)
cmd = [sys.executable, str(checker)]
if args.all:
cmd.append("--all")
elif args.repo:
cmd.extend(["--repo", args.repo])
if args.repo_path:
cmd.extend(["--repo-path", str(Path(args.repo_path).expanduser().resolve())])
else:
cmd.append("--here")
if args.path:
cmd.append(str(Path(args.path).expanduser().resolve()))
cmd.append("--fix")
if args.remote:
cmd.append("--remote")
if args.no_writeback:
cmd.append("--no-writeback")
if args.archive_closed:
cmd.append("--archive-closed")
if args.archive_workplan:
cmd.extend(["--archive-workplan", args.archive_workplan])
if args.archive_date:
cmd.extend(["--archive-date", args.archive_date])
if args.api_base:
cmd.extend(["--api-base", args.api_base])
if args.as_json:
cmd.append("--json")
if args.max_seconds is not None:
cmd.extend(["--max-seconds", str(args.max_seconds)])
result = subprocess.run(cmd)
exit_code = result.returncode
if exit_code == 2 and not args.strict_warnings:
exit_code = 0
sys.exit(exit_code)
def cmd_create_workstream(args: argparse.Namespace) -> None:
"""Create a workstream under a domain's topic."""
_api_get("/state/health")
@@ -466,6 +516,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:
@@ -534,6 +633,30 @@ def main() -> None:
ing.add_argument("--slug", default=None, help="Repo slug (auto-detected from path if omitted)")
ing.add_argument("--dry-run", action="store_true", help="Parse lockfiles but do not submit to API")
# fix-consistency
fix = sub.add_parser(
"fix-consistency",
help="Reconcile workplan files with State Hub from the current repo",
)
target = fix.add_mutually_exclusive_group()
target.add_argument("--repo", default=None, help="Registered repo slug; defaults to inferring from --path")
target.add_argument("--all", action="store_true", help="Fix all registered repos with a visible path")
fix.add_argument("--path", default=os.getcwd(), help="Repo checkout to infer from (defaults to cwd)")
fix.add_argument("--repo-path", default=None, help="Override repo path when using --repo")
fix.add_argument("--remote", action="store_true", help="Pull before fixing; requires --repo or --all")
fix.add_argument("--max-seconds", type=int, default=None, help="Wall-clock budget for --remote --all")
fix.add_argument("--no-writeback", action="store_true", help="Disable DB-to-file status writeback")
fix.add_argument("--archive-closed", action="store_true", help="Archive closed root workplans after fixing")
fix.add_argument("--archive-workplan", default=None, help="Archive only the matching workplan id or filename")
fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed")
fix.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
fix.add_argument("--json", action="store_true", dest="as_json", help="Output JSON from the checker")
fix.add_argument(
"--strict-warnings",
action="store_true",
help="Preserve checker exit code 2 for warnings-only runs",
)
# create-workstream
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under")
@@ -550,17 +673,54 @@ 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)
elif args.command == "ingest-sbom":
cmd_ingest_sbom(args)
elif args.command == "fix-consistency":
cmd_fix_consistency(args)
elif args.command == "create-workstream":
cmd_create_workstream(args)
elif args.command == "create-task":

View File

@@ -81,6 +81,7 @@ export default {
{ name: "Interventions", path: "/interventions" },
{ name: "Tasks", path: "/tasks" },
{ name: "UI Feedback", path: "/ui-feedback" },
{ name: "Suggestions", path: "/suggestions" },
{ name: "WSJF Triage", path: "/wsjf-triage" },
],
},
@@ -122,6 +123,7 @@ export default {
{ name: "Workstream Health", path: "/docs/workstream-health-index" },
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
{ name: "Workstreams", path: "/docs/workstreams" },
{ name: "Suggestions", path: "/docs/suggestions" },
{ name: "WSJF Triage", path: "/docs/wsjf-triage" },
],
},

View File

@@ -227,5 +227,8 @@ and age in days.
---
*Capability requests are a sanctioned write use case of the State Hub alongside
`resolve_decision` and `get_next_steps`. They do not originate in workplan files —
`resolve_decision`, `get_next_steps`, and the suggestion backlog writes
(`create_suggestion`, `vet_suggestion`, `decline_suggestion`,
`promote_suggestion_to_task`, `bump_suggestion_relevance`). They do not
originate in workplan files —
they are operational coordination.*

View File

@@ -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

View 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.*

View File

@@ -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
statehub fix-consistency
```

View File

@@ -82,7 +82,7 @@ Invalidation). The practical consequence:
| `uv.lock`, `package-lock.json`, etc. | SBOM entries + licence risk | `make ingest-sbom REPO=` |
| `tpsc.yaml` | Third-party service declarations + GDPR warnings | `make ingest-tpsc REPO=` |
| `SCOPE.md` capability blocks | Capability catalog | `make ingest-capabilities REPO=` |
| `workplans/*.md` | Workstream + task status | `make fix-consistency REPO=` |
| `workplans/*.md` | Workstream + task status | `statehub fix-consistency` |
| Repo files + DB records | DoI compliance tier | Fingerprint cache, auto-refreshed on read |
---

View File

@@ -0,0 +1,49 @@
# Demand-Weighted Suggestion Backlog
The `/suggestions` page shows persisted **gated needs** that are not yet real
tasks. Each unmet lookup increments `relevance`, which raises WSJF ranking.
## Stages
| Stage | Meaning |
|-------|---------|
| `suggestion` | Recorded need, not yet vetted |
| `requirement` | Vetted with structured fields and notes |
| `promoted` | Became a real `Task` (`promoted_task_id` set) |
| `declined` | Rejected; terminal |
## WSJF projection
```text
cost_of_delay = base_value + (relevance_weight × relevance)
wsjf = cost_of_delay / job_size
```
`GET /suggestions?rank=wsjf` returns open suggestions/requirements ordered by
score. Promoted and declined entries are excluded unless
`include_terminal=true`.
## Sanctioned writes
MCP and REST:
- `create_suggestion` / `POST /suggestions/`
- `vet_suggestion` / `POST /suggestions/{id}/vet`
- `decline_suggestion` / `POST /suggestions/{id}/decline`
- `promote_suggestion_to_task` / `POST /suggestions/{id}/promote`
- `bump_suggestion_relevance` / `POST /suggestions/{id}/bump-relevance`
Relevance also bumps automatically when:
- `GET /state/next_steps` surfaces open suggestions
- A `CapabilityRequest` matches an open suggestion
## Daily triage
`GET /state/summary` includes `ranked_suggestions` for the activity-core
`daily_triage_digest` resolver. See [WSJF Triage](/docs/wsjf-triage).
## Origin
Motivated by ops-warden `WARDEN-WP-0012` gated routing scenarios. Example
backfill: `scripts/seed_wp0012_suggestions.py`.

View File

@@ -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,

View File

@@ -0,0 +1,79 @@
---
title: Suggestions
---
```js
import {apiFetch, pollDelay, waitForVisible} from "./components/config.js";
const POLL = 30_000;
```
```js
const sugState = (async function*() {
let failures = 0;
while (true) {
let data = [], ok = false;
try {
const r = await apiFetch("/suggestions/?rank=wsjf&limit=100");
ok = r.ok;
data = ok ? await r.json() : [];
} catch {}
failures = ok ? 0 : failures + 1;
yield {data, ok, ts: new Date()};
await waitForVisible(pollDelay({ok, base: POLL, failures}));
}
})();
```
```js
const suggestions = sugState.data ?? [];
const _ok = sugState.ok ?? false;
const _ts = sugState.ts;
```
# Demand-Weighted Suggestions
```js
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/suggestions"); }
display(html`<p class="dim">Ranked by WSJF = (base_value + relevance_weight × relevance) / job_size. Gated needs accrue relevance when unmet.</p>`);
display(html`<p class="dim"><a href="/wsjf-triage">Daily WSJF triage</a> consumes this backlog in its digest.</p>`);
```
```js
const stageBadge = (stage) => {
const colors = {
suggestion: "#6b7280",
requirement: "#2563eb",
promoted: "#059669",
declined: "#9ca3af",
};
return html`<span style="font-size:0.75rem;padding:0.1rem 0.45rem;border-radius:4px;background:${colors[stage] ?? "#ccc'};color:#fff">${stage}</span>`;
};
const rows = suggestions.map((s) => html`<tr>
<td>${stageBadge(s.stage)}</td>
<td><strong>${s.title}</strong>${s.origin_ref ? html`<br/><code style="font-size:0.75rem">${s.origin_ref}</code>` : ""}</td>
<td>${s.domain_slug || "—"}</td>
<td style="text-align:right">${s.relevance}</td>
<td style="text-align:right">${s.wsjf?.toFixed?.(1) ?? s.wsjf}</td>
<td style="font-size:0.8rem">${s.last_requested_at ? new Date(s.last_requested_at).toLocaleString() : "—"}</td>
</tr>`);
display(html`<table class="dashboard-table" style="width:100%;font-size:0.88rem">
<thead><tr>
<th>Stage</th><th>Title</th><th>Domain</th><th>Relevance</th><th>WSJF</th><th>Last requested</th>
</tr></thead>
<tbody>${rows.length ? rows : html`<tr><td colspan="6" class="dim">No open suggestions yet.</td></tr>`}</tbody>
</table>`);
```

View File

@@ -219,7 +219,7 @@ injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/wsjf-triage"); }
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on.</p>`);
display(html`<p class="triage-subtitle">Daily State Hub triage from activity-core. Recommendations are advisory; the operator and workplan owners decide what to act on. Ranked <a href="/suggestions">suggestion backlog</a> feeds the digest.</p>`);
display(html`<div class="triage-latest">
<span>Last updated</span>
<strong>${latestReport ? fmtDateTime(latestReport.created_at) : "No daily_triage events yet"}</strong>

View File

@@ -0,0 +1,90 @@
# State Hub Railiance Deployment Handoff
This directory contains the State Hub deployment handoff for `CUST-WP-0011`.
It is source-owned by `state-hub` and split along the Railiance ownership
boundaries used for the actual cluster rollout.
## Ownership
- `deploy/railiance/platform/` is the `railiance-platform` handoff for the
`state-hub-db` CloudNativePG cluster, database bootstrap credential, and
database NetworkPolicies in the `databases` namespace.
- `deploy/railiance/apps/` is the `railiance-apps` handoff for the State Hub API
Helm chart, non-secret production values, and app namespace runtime Secret
template.
- Runtime secret values are not stored here. Replace placeholder passwords only
in an operator-controlled file, then encrypt or deliver through the approved
platform secret path.
## Image
The current image is pinned to:
```text
gitea.coulomb.social/coulomb/state-hub:f2e042a
```
railiance01 has already pulled this tag with `crictl`, and the image serves
`GET /state/health` against the local WSL database in smoke testing.
## Render And Dry-Run
Render the app chart without touching the cluster:
```bash
make railiance-state-hub-render
```
Run client-side Kubernetes validation for the platform manifests, app Secret
template, and rendered chart:
```bash
make railiance-state-hub-client-dry-run
```
Run server-side dry-run against the configured representative cluster:
```bash
KUBECONFIG=~/.kube/config-hosteurope make railiance-state-hub-server-dry-run
```
Server-side dry-run requires the CNPG CRDs, namespace permissions, and dry-run
permission for resources in `databases` and `state-hub`.
Before the `state-hub` namespace exists, Kubernetes cannot server-dry-run namespaced app
objects into that namespace because dry-run Namespace creation is not persisted.
The Make target therefore server-validates the platform and Namespace manifests,
then falls back to client dry-run for namespaced app manifests with an explicit
notice.
## Promotion Notes
Platform promotion into `railiance-platform`:
- copy `platform/state-hub-db-credentials.sops.yaml.template` to a real SOPS
secret file with an operator-generated password;
- apply or GitOps-manage `platform/state-hub-db-cluster.yaml`;
- apply or GitOps-manage `platform/state-hub-db-networkpolicies.yaml`.
App promotion into `railiance-apps`:
- copy `apps/charts/state-hub/` to `charts/state-hub/`;
- copy `apps/helm/state-hub-values.yaml` to `helm/state-hub-values.yaml`;
- apply or GitOps-manage `apps/manifests/state-hub-namespace.yaml`;
- create `state-hub-env` in the `state-hub` namespace from the approved
secret-delivery path;
- deploy with Helm using the production values file, which sets
`namespace.create=false`, only after `state-hub-db` is healthy.
## Runtime Secret Contract
The app chart expects a Kubernetes Secret named `state-hub-env` in the
`state-hub` namespace with at least:
```text
DATABASE_URL=postgresql+asyncpg://state_hub:<url-encoded-password>@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub
```
Optional runtime settings such as `CORS_ORIGINS` can live in the chart
ConfigMap. The default chart keeps public ingress disabled; access should use
the existing private tunnel/ops-bridge path until a separate exposure decision
is recorded.

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: state-hub
description: State Hub API service for private Railiance operation
type: application
version: 0.1.0
appVersion: "f2e042a"

View File

@@ -0,0 +1,26 @@
{{- define "statehub.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "statehub.labels" -}}
app: {{ include "statehub.fullname" . }}
app.kubernetes.io/name: {{ include "statehub.fullname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/part-of: railiance-apps
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
railiance.io/layer: s5-app
{{- end -}}
{{- define "statehub.selectorLabels" -}}
app: {{ include "statehub.fullname" . }}
{{- end -}}
{{- define "statehub.image" -}}
{{- if not .Values.image.tag -}}
{{- fail "image.tag is required - pin it in deploy/railiance/apps/helm/state-hub-values.yaml or pass --set image.tag=<sha>" -}}
{{- end -}}
{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}}
{{- end -}}

View File

@@ -0,0 +1,9 @@
{{- if .Values.config.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Values.config.name }}
labels: {{- include "statehub.labels" . | nindent 4 }}
data:
CORS_ORIGINS: {{ .Values.config.corsOrigins | quote }}
{{- end }}

View File

@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "statehub.fullname" . }}
labels: {{- include "statehub.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {{- include "statehub.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels: {{- include "statehub.labels" . | nindent 8 }}
spec:
securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets: {{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: state-hub
image: {{ include "statehub.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext: {{- toYaml .Values.securityContext | nindent 12 }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
envFrom:
{{- if .Values.config.enabled }}
- configMapRef:
name: {{ .Values.config.name | quote }}
{{- end }}
- secretRef:
name: {{ .Values.secret.name | quote }}
{{- if .Values.probes.enabled }}
readinessProbe:
httpGet:
path: {{ .Values.probes.path }}
port: {{ .Values.probes.port }}
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
livenessProbe:
httpGet:
path: {{ .Values.probes.path }}
port: {{ .Values.probes.port }}
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
{{- end }}
resources: {{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations: {{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,28 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "statehub.fullname" . }}
labels: {{- include "statehub.labels" . | nindent 4 }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ include "statehub.fullname" . }}-tls
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "statehub.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}

View File

@@ -0,0 +1,8 @@
{{- if .Values.namespace.create }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Release.Namespace }}
labels:
{{- toYaml .Values.namespace.labels | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "statehub.fullname" . }}
labels: {{- include "statehub.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: http
selector: {{- include "statehub.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,67 @@
image:
repository: gitea.coulomb.social/coulomb/state-hub
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
replicaCount: 1
namespace:
create: true
labels:
railiance.io/postgres-client: state-hub-db
railiance.io/layer: s5-app
service:
type: ClusterIP
port: 8000
targetPort: 8000
config:
enabled: true
name: state-hub-config
corsOrigins: "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001"
secret:
name: state-hub-env
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 2Gi
ingress:
enabled: false
className: traefik
host: state-hub.coulomb.social
tls: true
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
probes:
enabled: true
path: /state/health
port: 8000
liveness:
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readiness:
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
podSecurityContext: {}
securityContext: {}
nodeSelector: {}
tolerations: []
affinity: {}

View File

@@ -0,0 +1,11 @@
# Production values for the State Hub Railiance chart handoff.
# Non-secret values only. DATABASE_URL comes from the Secret `state-hub-env`.
namespace:
create: false
image:
tag: "f2e042a"
ingress:
enabled: false

Some files were not shown because too many files have changed in this diff Show More