Compare commits

...

62 Commits

Author SHA1 Message Date
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
6c0a2d537c 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 11:58:01 +02:00
4295b537e2 STATE-WP-0065: backfill P1-P4 state_hub_task_ids [consistency]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:57:58 +02:00
b7484615eb 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 11:57:27 +02:00
1620701ae4 Add STATE-WP-0065: repo-anchored classification spine (CUST-WP-0050 impl)
Re-homed implementation of CUST-WP-0050 Phase 3-4. P1 merges the schema
redesign, data migration, and workstream->workplan rename into one Alembic
window; P2 API/MCP/validation; P3 auto-registration; P4 surfaces & cutover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:52:55 +02:00
c4c38e1697 Mark .repo-classification.yaml human-reviewed (CUST-WP-0050 T02)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:40:43 +02:00
9ba9eb95da Reclassify as tooling (CUST-WP-0050 T02)
Apply the new 'tooling' category (reusable internal tooling/infrastructure)
from the Repo Classification Standard. First-pass agent classification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:06:01 +02:00
2d22e79c7c Add repo classification (CUST-WP-0050 T02)
First-pass agent classification per the Repo Classification Standard v1.0
(canon-repo-classification); pending human review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:44:46 +02:00
39ed5459b9 finish(STATE-WP-0064): cut over scheduler and split sweep errors from failures
STATE-WP-0064 cutover (state-hub only):
- Retire local custodian-sync.timer; archive units under infra/systemd/archived/
- Mark workplan finished; update infra/README, cron-migration, runbook, AGENTS.md
- Point activity-core-delegation at the consistency-sweep runbook

Consistency engine — automation error vs assessment failure:
- C-00 is an automation error; C-01..C-23 assessment failures are recorded
  for follow-up but no longer fail --remote --all scheduled sweeps (exit 0)
- Skip workplans/README.md in the workplan glob (human index, not a workplan)
- Progress events and compare script expose automation_error and
  assessment_failures separately from exit_code
2026-06-22 01:20:59 +02:00
270033a50d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 23:21:29 +02:00
bf377788eb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 21:47:09 +02:00
ab14e77e77 feat(STATE-WP-0064): start parallel week with source-tagged sweep runners
Tag consistency_sweep_remote_all progress events by source, route the local
timer through the API, add a parallel-week comparison script, and document
the 2026-06-21 to 2026-06-28 observation window for T03.
2026-06-21 21:46:43 +02:00
696b628142 chore(STATE-WP-0063): finish with accelerated 3h validation close-out
Replace the 24h observation wait with evidence from post-repair sweeps:
seven consecutive custodian-sync passes, four hourly RecentlyOnScope
events, and a stable state-hub-railiance01 tunnel.
2026-06-21 21:37:44 +02:00
83d266965f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 21:37:16 +02:00
7a1de91bd7 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 21:11:24 +02:00
821b5d6c89 fix(STATE-WP-0064): parse consistency sweep stdout with skip prefixes
Extract the JSON payload from mixed script output and document Railiance01
kubectl sync steps. Mark T02 done after cluster bridge and resolver canaries.
2026-06-21 20:56:35 +02:00
acc5bea15b docs: fix schedule sync commands in consistency sweep runbook
Replace nonexistent make sync-schedules with admin/sync and the
activity_core.sync_schedules CLI documented in activity-core runbook.
2026-06-21 20:28:04 +02:00
2b0c05ea4c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 20:24:29 +02:00
5a7a6ef5ee feat(STATE-WP-0064): add consistency sweep remote-all API endpoint
Expose POST /consistency/sweep/remote-all so activity-core can trigger
the workstation ADR-001 remote-all sweep via the bridge tunnel pattern.
Records consistency_sweep_remote_all progress events and documents the
cutover runbook while the local custodian-sync timer remains interim.
2026-06-21 20:19:22 +02:00
0fdebc6aa8 docs(history): cross-link BRIDGE-WP-0005 restart cleanup workplan 2026-06-21 20:12:17 +02:00
323599f2fc docs(state-hub): STATE-WP-0063 T03 done — tunnel cleanup restored activity-core
Document stale remote sshd forward on Railiance01 :18000 as root cause of
reconnect loop; T03 verified after bridge maintenance cleanup and manual
canaries for hourly RecentlyOnScope and daily WSJF triage.
2026-06-21 19:47:56 +02:00
dff8cfe128 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 19:47:46 +02:00
1b33a27a56 fix(state-hub): STATE-WP-0063 T01/T02/T04 — restore local consistency sync
Point custodian-sync systemd units at /home/worsch/state-hub and uv run;
add infra/systemd templates and README interim guidance. Document T02
diagnosis (activity-core schedules fire; ops-bridge tunnel gaps cause State
Hub connection refused). T04 crontab path fixed locally; T03/T05 remain open.
2026-06-21 18:06:34 +02:00
661eb01e45 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 18:06:22 +02:00
3d5e354ff8 docs(state-hub): weekend automation assessment and repair workplans
Persist the Fri-evening→Sun-afternoon automation gap assessment in
history/, and add STATE-WP-0063 (repair broken paths and cluster
reachability) plus STATE-WP-0064 (move State Hub consistency sync to
Railiance01 via activity-core). Workplans registered in State Hub via
fix-consistency.
2026-06-21 17:32:44 +02:00
25cda24661 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-21:
  - update .custodian-brief.md for state-hub
2026-06-21 17:32:23 +02:00
649ab50788 Write back state-hub IDs for STATE-WP-0061
fix-consistency registered the workstream and tasks and wrote their UUIDs into
the workplan frontmatter/task blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:22:45 +02:00
ce82ada0fa STATE-WP-0062 T5: docs, first-party↔repo test, mark workplan finished
- Add /docs/services reference (two-dimension model, persistence, API) and a
  pointer note from /docs/tpsc; add it to the Reference nav.
- Add a test asserting first_party.repo_slug resolves to a managed_repos FK
  (8 services tests green).
- Mark STATE-WP-0062 tasks done / status finished.

Known classes seeded in the live catalog via the API (Gitea, Postgres as
self-hosted/third-party; State Hub as self-hosted/first-party at Level 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:16:37 +02:00
f14c225dd9 STATE-WP-0062 T4: Service DoM uses "Level" not "Tier"
Rename Tier 1/2/3 -> Level 1/2/3 (Core/Standard/Full) in the Service DoM policy
and the checklist header to "Level", aligning with the service_catalog
maturity_level column. The DoI tier subsystem is intentionally untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:35 +02:00
d68de69fe6 STATE-WP-0062 T3: Services nav section + First/Self Hosted pages
Replace the single "Services (TPSC)" nav entry with a Services section:
Third Party (existing /tpsc cloud-third-party view), First Party
(/services/first-party — Service Maturity Level + dev-repo columns,
development_type=first_party), and Self Hosted (/services/self-hosted —
self_hosted third-party OSS with upstream/host/runbook). New pages are filtered
views over /services/catalog and degrade to an empty-state if the API is offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:35 +02:00
77689fbfb2 STATE-WP-0062 T2: /services catalog API over the two-dimension model
Add a local /services router (source of truth for the catalog itself):
- GET /services/catalog with hosting_type / development_type / maturity_level /
  status filters (eager-loads all four extensions)
- GET /services/{slug}
- POST /services/catalog upsert-by-slug, applying the dimension extensions;
  first_party.repo_slug resolves to a managed_repos FK.

Extensions are read/written via session.get (not the relationship attribute) to
avoid async lazy-load. /tpsc/* is left intact for dependency snapshots. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:56:19 +02:00
0192dc786f STATE-WP-0062 T1: two-dimension service_catalog model + migration
Add ServiceCatalog core (hosting_type, development_type, maturity_level) plus
1:1 per-dimension extension tables (service_third_party, service_first_party,
service_cloud, service_self_hosted) keyed by service_id. Migration creates the
tables and copies existing tpsc_catalog rows into service_catalog as
(cloud_hosted, third_party), reusing the tpsc_catalog id as the service_catalog
id so existing tpsc_entries.catalog_id keep resolving without a column change.
GDPR/data-processing fields move to service_cloud; pricing_model to
service_third_party.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:46:07 +02:00
f48206424e Add STATE-WP-0062: two-dimension service catalog workplan
Proposed plan to restructure the single TPSC services view into a catalog
classified along two orthogonal dimensions — hosting (self_hosted|cloud_hosted)
and development (first_party|third_party). Common fields live in a core
service_catalog table; dimension-specific data composes via extension tables
(third-party upstream contacts, first-party repo link, cloud data-processing/
GDPR, self-hosted infra). Existing TPSC migrates to (cloud_hosted, third_party)
with /tpsc/* kept as a back-compat view. Includes Services nav section and the
Tier->Level rename scoped to the Service DoM policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:24:20 +02:00
187 changed files with 11996 additions and 1879 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,8 +1,8 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — state-hub
**Domain:** custodian
**Last synced:** 2026-06-07 18:02 UTC
**Domain:** infotech
**Last synced:** 2026-06-25 14:02 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
@@ -21,13 +21,9 @@ Progress: 0/8 done | workstream_id: `8d0c1b5d-44da-4b91-8357-e6526d3e0a85`
- … and 1 more open tasks
### Pragmatic State Hub Migration to railiance01
Progress: 2/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
Progress: 6/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
**Open tasks:**
- ► T03 — Build and push State Hub container image `79908ade`
- · T04 — Deploy to cluster and run Alembic migrations `a7baf2eb`
- · T05 — Migrate data from WSL2 to cluster `a307dd46`
- · T06 — Drill cluster backup restore `03753b88`
- · T07 — Cutover: redirect MCP config to cluster `ff1de25e`
- · T08 — Stabilisation period (2 weeks minimum) `e06a59a0`
- · T09 — Retire WSL2 instance `d75a2d49`
@@ -36,6 +32,6 @@ Progress: 2/9 done | workstream_id: `967baafb-d92d-405a-ba0b-0d00d37c4940`
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("custodian")`
`get_domain_summary("infotech")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

30
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,30 @@
# Repo classification (Repo Classification Standard v1.0).
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: human
category: tooling
domain: infotech
secondary_domains:
- agents
capability_tags:
- coordination
- knowledge
- platform
- observability
- governance
business_stake:
- technology
- operations
- product
- intelligence
- automation
business_mechanics:
- coordination
- control
- operation
- adaptation
notes: Live coordination service (PostgreSQL+FastAPI+MCP+dashboard); versioned, daily use.
infotech with agents secondary; classified product as a reusable, offerable service component.

View File

@@ -4,7 +4,7 @@
**Purpose:** Standalone State Hub service repository extracted from the-custodian/state-hub. Owns the FastAPI API, MCP server, dashboard, migrations, consistency tooling, and operational docs.
**Domain:** custodian
**Domain:** infotech
**Repo slug:** state-hub
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `STATE-WP-`
@@ -27,8 +27,8 @@ there is no MCP server for Codex agents.
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workplans for this domain
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Check inbox
@@ -80,7 +80,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workplans (offline-safe)
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
@@ -151,6 +151,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)
@@ -176,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
id: STATE-WP-NNNN
type: workplan
title: "..."
domain: custodian
domain: infotech
repo: state-hub
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
@@ -187,10 +192,6 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
`state_hub_workstream_id` is the legacy bridge field for the current State Hub
database identity. Prefer workplan-named API routes for new client code while
this bridge field remains in compatibility use.
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.

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

@@ -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 ?= b536741
RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
@@ -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

@@ -111,7 +111,9 @@ custodian register-project # register cwd as a Custodian project
| `make db` | Start postgres container |
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
| `make migrate` | `alembic upgrade head` |
| `make seed` | Insert 6 canonical topics |
| `make seed` | Insert 6 canonical topics (legacy bootstrap) |
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
| `make register-from-classification-all` | Bulk reclassify all repos with classification files |
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
@@ -125,28 +127,30 @@ custodian register-project # register cwd as a Custodian project
## Database Schema
Five tables in dependency order:
Repo-anchored coordination spine (STATE-WP-0065):
```
topics
└── workstreams
└── tasks (self-FK: parent_task_id)
domains (14 market domains: infotech, financials, communication, …)
managed_repos (classification: category, domain, capability_tags, business_stake, …)
└── workplans (repo_id required; topic_id optional legacy tag)
└── tasks
└── progress_events
decisions (FK: topic_id, workstream_id — at least one required)
└── progress_events
topics (optional cross-repo tag; domain_id → market domain)
decisions (FK: topic_id and/or workplan_id)
```
### Enums
Each registered repo carries a committed `.repo-classification.yaml` (canon
standard v1.0). Registration and reclassification use
`make register-from-classification`.
| Enum | Values |
### Key enums / vocabularies
| Field | Values |
|------|--------|
| `topic_status` | `active` · `paused` · `archived` |
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `workplan_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
| `task_priority` | `low` · `medium` · `high` · `critical` |
| `decision_type` | `made` · `pending` |
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
| `repo category` | `experimental` · `research` · `project` · `tooling` · `product` · `business` |
| `market domain` | 14 fixed slugs — see `the-custodian/canon/standards/repo-classification.allowed.yaml` |
### Governance constraints encoded in schema
@@ -181,6 +185,14 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
}
```
**Caching:** responses are revision-gated — the API compares cheap per-table
`MAX(updated_at)` / `MAX(created_at)` watermarks before rebuilding. Unchanged
data returns the cached snapshot (`X-StateHub-Cache: hit-revision`). When core
data changes, the last good snapshot may be served immediately while a
background refresh runs (`X-StateHub-Cache: stale`). Force a synchronous rebuild
with `?refresh=true` or `Cache-Control: no-cache`. Infrastructure probes should
use `/state/health`, not `/state/summary`.
### Router summary
| Prefix | Operations |
@@ -226,9 +238,11 @@ See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster tha
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `create_workplan` · `update_workplan_status` · `register_repo_from_classification`
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
**Resources**: `state://summary` · `state://topics` · `state://workplans/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
Legacy `workstream_*` tool names remain as aliases — see `mcp_server/TOOLS.md`.
---

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,7 +12,8 @@ telemetry.
- PostgreSQL schema and Alembic migrations
- FastMCP server and tool reference
- Observable dashboard
- repo registration and consistency synchronization
- repo registration (classification-driven) and consistency synchronization
- repo classification spine (14 market domains, `.repo-classification.yaml`)
- task-flow engine and flow definitions
- SBOM, contribution, capability, TPSC, DoI, token, and interface-change tracking
- State Hub tests, operational docs, policies, prompts, and local infra

290
api/classification.py Normal file
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,12 +11,14 @@ from starlette.responses import Response as StarletteResponse
from api.database import engine
from api.events import shutdown_publisher
from api.services.write_idempotency import WriteIdempotencyMiddleware
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
from api.routers import token_events
from api.routers import interface_changes
from api.routers import flows
from api.routers import recently_on_scope
from api.routers import consistency_sweep
from api.routers import reconciliation
from api.routers import execution
from api.routers import fabric
@@ -90,18 +92,20 @@ _default_dashboard_origins = [
_cors_env = os.getenv("CORS_ORIGINS", ",".join(_default_dashboard_origins))
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
app.add_middleware(WriteIdempotencyMiddleware)
app.add_middleware(ETagMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
allow_headers=["Content-Type", "If-None-Match"],
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache"],
allow_headers=["Content-Type", "If-None-Match", "Idempotency-Key", "X-StateHub-Source-Agent", "X-StateHub-Source-Host"],
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache", "X-StateHub-Idempotency-Replay"],
)
app.include_router(domains.router)
app.include_router(recently_on_scope.hourly_router)
app.include_router(recently_on_scope.router)
app.include_router(consistency_sweep.router)
app.include_router(repos.router)
app.include_router(topics.router)
app.include_router(workstreams.router)
@@ -120,6 +124,7 @@ app.include_router(sbom.router)
app.include_router(messages.router)
app.include_router(capability_requests.router)
app.include_router(tpsc.router)
app.include_router(services.router)
app.include_router(token_events.router)
app.include_router(interface_changes.router)
app.include_router(flows.router)
@@ -133,4 +138,4 @@ app.include_router(policy.router)
@app.get("/", include_in_schema=False)
async def root():
return {"service": "state-hub", "docs": "/docs"}
return {"service": "dev-hub", "docs": "/docs"}

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
@@ -18,12 +20,20 @@ from api.models.agent_message import AgentMessage
from api.models.capability_catalog import CapabilityCatalog
from api.models.capability_request import CapabilityRequest
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
from api.models.service_catalog import (
ServiceCatalog,
ServiceThirdParty,
ServiceFirstParty,
ServiceCloud,
ServiceSelfHosted,
)
from api.models.doi_cache import DOICache
from api.models.token_event import TokenEvent
from api.models.interface_change import InterfaceChange
from api.models.workplan_launch_request import WorkplanLaunchRequest
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.models.write_idempotency_key import WriteIdempotencyKey
__all__ = [
"Base",
@@ -32,6 +42,8 @@ __all__ = [
"Topic", "TopicStatus",
"ManagedRepo",
"RepoGoal", "RepoGoalStatus",
"Workplan",
"WorkplanDependency",
"Workstream",
"WorkstreamDependency",
"Task", "TaskStatus", "TaskPriority",
@@ -46,10 +58,13 @@ __all__ = [
"CapabilityCatalog",
"CapabilityRequest",
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
"ServiceCatalog", "ServiceThirdParty", "ServiceFirstParty",
"ServiceCloud", "ServiceSelfHosted",
"DOICache",
"TokenEvent",
"InterfaceChange",
"WorkplanLaunchRequest",
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
"LegacyInterface", "LegacyInterfaceUsageBucket",
]
"WriteIdempotencyKey",
]

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

View File

@@ -0,0 +1,121 @@
"""Two-dimension service catalog (STATE-WP-0062).
Every service is classified along two orthogonal dimensions:
- hosting_type: self_hosted (coulomb operates it) | cloud_hosted (consumed)
- development_type: first_party (coulomb develops it) | third_party (external)
Common fields live in ``ServiceCatalog``; dimension-specific data composes via
1:1 extension tables (``service_id`` is both PK and FK), so a self-hosted
first-party service carries the self-hosted *and* first-party extensions without
needing a bespoke per-class shape.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSON, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base
class ServiceCatalog(Base):
__tablename__ = "service_catalog"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
owner_or_provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# status: active | deprecated
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
# hosting_type: self_hosted | cloud_hosted
hosting_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# development_type: first_party | third_party
development_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# Service DoM Level (1=Operable, 2=Observable, 3=Mature); NULL = unassessed
maturity_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
third_party: Mapped["ServiceThirdParty | None"] = relationship(
back_populates="service", uselist=False, cascade="all, delete-orphan")
first_party: Mapped["ServiceFirstParty | None"] = relationship(
back_populates="service", uselist=False, cascade="all, delete-orphan")
cloud: Mapped["ServiceCloud | None"] = relationship(
back_populates="service", uselist=False, cascade="all, delete-orphan")
self_hosted: Mapped["ServiceSelfHosted | None"] = relationship(
back_populates="service", uselist=False, cascade="all, delete-orphan")
class ServiceThirdParty(Base):
"""Extension for development_type = third_party (coulomb is not dev-responsible)."""
__tablename__ = "service_third_party"
service_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
# pricing_model: free | paid | freemium | usage_based | unknown
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
upstream_packages: Mapped[list | None] = mapped_column(JSON, nullable=True)
upstream_contacts: Mapped[list | None] = mapped_column(JSON, nullable=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
support_url: Mapped[str | None] = mapped_column(Text, nullable=True)
license: Mapped[str | None] = mapped_column(String(100), nullable=True)
service: Mapped["ServiceCatalog"] = relationship(back_populates="third_party")
class ServiceFirstParty(Base):
"""Extension for development_type = first_party (coulomb develops it)."""
__tablename__ = "service_first_party"
service_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
repo_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True)
owning_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
service: Mapped["ServiceCatalog"] = relationship(back_populates="first_party")
class ServiceCloud(Base):
"""Extension for hosting_type = cloud_hosted (data is processed off coulomb infra).
Holds the data-processor concerns that were the heart of the old TPSC record;
they apply whenever data leaves coulomb infra, independent of who built it.
"""
__tablename__ = "service_cloud"
service_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
# gdpr_maturity (CNIL/IAPP CMMI-aligned):
# unknown | non_compliant | initial | developing | defined | managed | certified
gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True)
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
service: Mapped["ServiceCatalog"] = relationship(back_populates="cloud")
class ServiceSelfHosted(Base):
"""Extension for hosting_type = self_hosted (coulomb operates the service)."""
__tablename__ = "service_self_hosted"
service_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
# three-helix instance / host the service runs on
helix_instance: Mapped[str | None] = mapped_column(String(100), nullable=True)
host_node: Mapped[str | None] = mapped_column(String(100), nullable=True)
deployment_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
runbook_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
# upstream OSS project when the self-hosted service is third-party software
upstream_oss_project: Mapped[str | None] = mapped_column(String(200), nullable=True)
service: Mapped["ServiceCatalog"] = relationship(back_populates="self_hosted")

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"

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

@@ -0,0 +1,70 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid
class Workplan(Base, TimestampMixin):
__tablename__ = "workplans"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid
)
topic_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="active", server_default="active"
)
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
launch_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="manual", server_default="manual", index=True
)
concurrency_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="sequential", server_default="sequential", index=True
)
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
repo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("repo_goals.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="workplans") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workplans", lazy="selectin") # noqa: F821
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
"Task", back_populates="workplan", lazy="selectin"
)
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
"Decision", back_populates="workplan", lazy="selectin"
)
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
"ProgressEvent", back_populates="workplan", lazy="selectin"
)
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
"WorkplanLaunchRequest", back_populates="workplan", lazy="selectin"
)

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
@@ -26,60 +25,58 @@ 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 _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 +95,16 @@ async def create_request(
),
)
await session.commit()
await session.refresh(req)
return req
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
req.fulfilling_workplan_id = body.fulfilling_workplan_id
@router.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 +113,14 @@ async def accept_request(
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
async def patch_request_status(
request_id: uuid.UUID,
async def _on_status_change(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestStatusPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, body.status)
req.status = body.status
if body.note:
req.resolution_note = body.note
now = datetime.now(tz=timezone.utc)
# Status-specific side effects
now: datetime,
) -> None:
if body.status == "completed":
req.completed_at = now
# Auto-unblock the blocking task
if req.blocking_task_id:
task = await session.get(Task, req.blocking_task_id)
if task and task.status == "wait":
@@ -200,23 +168,12 @@ async def patch_request_status(
body=f"Work on capability **{req.title}** is now in progress.",
)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
async def patch_request(
request_id: uuid.UUID,
async def _apply_capability_patch(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
priority, blocking_task_id, fulfilling_workstream_id.
Only fields present in the request body (non-None) are updated.
"""
req = await _get_request_or_404(request_id, session)
) -> bool:
corrections: list[str] = []
if body.catalog_entry_id is not None:
@@ -225,8 +182,6 @@ async def patch_request(
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
# Re-derive fulfilling domain from catalog entry
old_domain_id = req.fulfilling_domain_id
req.fulfilling_domain_id = entry.domain_id
corrections.append(
f"catalog_entry: {old_entry_id}{entry.id} ({entry.title}); "
@@ -241,49 +196,30 @@ async def patch_request(
req.blocking_task_id = body.blocking_task_id
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
if body.fulfilling_workstream_id is not None:
req.fulfilling_workstream_id = body.fulfilling_workstream_id
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
if body.fulfilling_workplan_id is not None:
req.fulfilling_workplan_id = body.fulfilling_workplan_id
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
if not corrections:
return req # no-op
return False
correction_note = "hub correction: " + "; ".join(corrections)
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
await session.commit()
await session.refresh(req)
return req
return True
# ---------------------------------------------------------------------------
# Dispute endpoints
# ---------------------------------------------------------------------------
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
async def dispute_request(
request_id: uuid.UUID,
async def _notify_on_dispute(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestDispute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, "routing_disputed")
now = datetime.now(tz=timezone.utc)
req.status = "routing_disputed"
req.dispute_reason = body.reason
req.disputed_by = body.disputed_by
req.dispute_suggested_domain = body.suggested_domain
req.disputed_at = now
now: datetime,
) -> None:
dispute_entry = (
f"disputed by {body.disputed_by}: {body.reason}"
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
)
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
# Notify custodian
_add_notification(
session,
from_agent=body.disputed_by,
@@ -297,7 +233,6 @@ async def dispute_request(
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
),
)
# Notify current fulfilling domain
if req.fulfilling_domain_slug:
_add_notification(
session,
@@ -312,52 +247,13 @@ async def dispute_request(
),
)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
async def reroute_request(
request_id: uuid.UUID,
async def _notify_on_reroute(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestReroute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Re-route a disputed request to a new domain. Resets to requested."""
req = await _get_request_or_404(request_id, session)
if req.status != "routing_disputed":
raise HTTPException(
status_code=422,
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
)
if body.catalog_entry_id is None and body.domain is None:
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
if body.catalog_entry_id is not None:
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
req.fulfilling_domain_id = entry.domain_id
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
else:
new_domain = await _resolve_domain(body.domain, session)
req.fulfilling_domain_id = new_domain.id
new_domain_slug = new_domain.slug
old_domain = req.dispute_suggested_domain or "unknown"
# Clear dispute fields
req.dispute_reason = None
req.disputed_by = None
req.dispute_suggested_domain = None
req.disputed_at = None
req.status = "requested"
reroute_entry = f"re-routed by {body.rerouted_by}{new_domain_slug}: {body.note}"
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
# Notify requester
new_domain_slug: str,
) -> None:
_add_notification(
session,
from_agent=body.rerouted_by,
@@ -368,7 +264,6 @@ async def reroute_request(
f"**Note:** {body.note}"
),
)
# Notify new fulfilling domain
_add_notification(
session,
from_agent=body.rerouted_by,
@@ -383,24 +278,20 @@ async def reroute_request(
),
)
await session.commit()
await session.refresh(req)
return req
# ---------------------------------------------------------------------------
# Routing algorithm
# ---------------------------------------------------------------------------
async def _route_capability(
session: AsyncSession, capability_type: str, title: str, description: str
async def _route_capability_match(
session: AsyncSession,
capability_type: str,
title: str,
description: str,
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
"""Find the best-matching catalog entry for a capability request.
Returns (domain_id, catalog_entry_id, routing_note).
Uses word-boundary matching on (title + description) combined to avoid
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
'ha' inside 'has').
"""
q = select(CapabilityCatalog).where(
CapabilityCatalog.capability_type == capability_type,
@@ -412,20 +303,19 @@ async def _route_capability(
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
if len(entries) == 1:
e = entries[0]
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
entry = entries[0]
return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})"
# Score by word-boundary keyword overlap against title + description combined
combined = f"{title} {description or ''}".lower()
scored: list[tuple[int, CapabilityCatalog]] = []
for entry in entries:
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
score = sum(
1 for kw in keywords
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined)
)
scored.append((score, entry))
scored.sort(key=lambda x: -x[0])
scored.sort(key=lambda item: -item[0])
best_score, best = scored[0]
if best_score == 0:
@@ -456,7 +346,6 @@ def _add_notification(
subject: str,
body: str,
) -> None:
"""Create an AgentMessage notification in the current session (no commit)."""
msg = AgentMessage(
from_agent=from_agent,
to_agent=to_agent,
@@ -466,21 +355,6 @@ def _add_notification(
session.add(msg)
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
async def _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 +374,44 @@ def _check_transition(current: str, target: str) -> None:
"flow_result": flow_result_to_dict(flow_result),
},
)
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
)
router.include_router(
create_capability_request_read_router(
get_session,
domain_model=Domain,
request_model=CapabilityRequest,
request_read_schema=CapabilityRequestRead,
)
)
router.include_router(
create_capability_request_write_router(
get_session,
domain_model=Domain,
catalog_model=CapabilityCatalog,
request_model=CapabilityRequest,
request_create_schema=CapabilityRequestCreate,
request_accept_schema=CapabilityRequestAccept,
request_patch_schema=CapabilityRequestPatch,
request_status_patch_schema=CapabilityRequestStatusPatch,
request_dispute_schema=CapabilityRequestDispute,
request_reroute_schema=CapabilityRequestReroute,
request_read_schema=CapabilityRequestRead,
route_request=_route_capability,
build_request=_build_capability_request,
on_request_persisted=_notify_on_create,
check_transition=_check_transition,
apply_accept_fields=_apply_accept_fields,
after_accept=_notify_on_accept,
after_status_change=_on_status_change,
apply_patch=_apply_capability_patch,
after_dispute=_notify_on_dispute,
after_reroute=_notify_on_reroute,
)
)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.schemas.consistency_sweep import (
ConsistencySweepRemoteAllGenerate,
ConsistencySweepRemoteAllRun,
)
from api.services.consistency_sweep import run_remote_all_sweep
router = APIRouter(prefix="/consistency/sweep", tags=["consistency"])
@router.post(
"/remote-all",
response_model=ConsistencySweepRemoteAllRun,
status_code=status.HTTP_201_CREATED,
)
async def sweep_remote_all(
body: ConsistencySweepRemoteAllGenerate,
session: AsyncSession = Depends(get_session),
) -> ConsistencySweepRemoteAllRun:
try:
return await run_remote_all_sweep(
session,
max_seconds=body.max_seconds,
source=body.source,
)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=500,
detail=f"Consistency sweep returned invalid JSON: {exc}",
) from exc

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,

143
api/routers/services.py Normal file
View File

@@ -0,0 +1,143 @@
"""Two-dimension service catalog API (STATE-WP-0062).
Read/write surface over service_catalog and its per-dimension extension tables.
The four service classes are queried by combining the hosting_type and
development_type filters. The legacy /tpsc routes remain for third-party
dependency snapshots; this router is the source of truth for the catalog itself.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.service_catalog import (
ServiceCatalog,
ServiceCloud,
ServiceFirstParty,
ServiceSelfHosted,
ServiceThirdParty,
)
from api.schemas.service import ServiceCatalogRead, ServiceUpsert
router = APIRouter(prefix="/services", tags=["services"])
_HOSTING = {"self_hosted", "cloud_hosted"}
_DEVELOPMENT = {"first_party", "third_party"}
_WITH_EXTENSIONS = (
selectinload(ServiceCatalog.third_party),
selectinload(ServiceCatalog.first_party),
selectinload(ServiceCatalog.cloud),
selectinload(ServiceCatalog.self_hosted),
)
@router.get("/catalog", response_model=list[ServiceCatalogRead])
async def list_services(
hosting_type: str | None = None,
development_type: str | None = None,
maturity_level: int | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[ServiceCatalog]:
q = select(ServiceCatalog).options(*_WITH_EXTENSIONS)
if hosting_type:
q = q.where(ServiceCatalog.hosting_type == hosting_type)
if development_type:
q = q.where(ServiceCatalog.development_type == development_type)
if maturity_level is not None:
q = q.where(ServiceCatalog.maturity_level == maturity_level)
if status:
q = q.where(ServiceCatalog.status == status)
q = q.order_by(ServiceCatalog.name.asc())
result = await session.execute(q)
return list(result.scalars().all())
@router.get("/{slug}", response_model=ServiceCatalogRead)
async def get_service(
slug: str,
session: AsyncSession = Depends(get_session),
) -> ServiceCatalog:
svc = await _resolve(slug, session)
if svc is None:
raise HTTPException(status_code=404, detail=f"Service '{slug}' not found")
return svc
@router.post("/catalog", response_model=ServiceCatalogRead, status_code=status.HTTP_201_CREATED)
async def upsert_service(
body: ServiceUpsert,
session: AsyncSession = Depends(get_session),
) -> ServiceCatalog:
if body.hosting_type not in _HOSTING:
raise HTTPException(status_code=422, detail=f"hosting_type must be one of {sorted(_HOSTING)}")
if body.development_type not in _DEVELOPMENT:
raise HTTPException(status_code=422, detail=f"development_type must be one of {sorted(_DEVELOPMENT)}")
svc = await _resolve(body.slug, session)
if svc is None:
svc = ServiceCatalog(slug=body.slug)
session.add(svc)
for field in ("name", "owner_or_provider", "category", "description",
"website_url", "status", "hosting_type", "development_type",
"maturity_level"):
setattr(svc, field, getattr(body, field))
await _apply_extensions(svc, body, session)
await session.commit()
return await _resolve(body.slug, session)
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _resolve(slug: str, session: AsyncSession) -> ServiceCatalog | None:
result = await session.execute(
select(ServiceCatalog).where(ServiceCatalog.slug == slug).options(*_WITH_EXTENSIONS)
)
return result.scalar_one_or_none()
async def _upsert_ext(model, service_id: uuid.UUID, data: dict, session: AsyncSession) -> None:
"""Create or update a 1:1 extension row keyed by service_id.
Fetched via session.get (not the relationship attribute) so we never trigger
a lazy relationship load on a freshly-created core row in async context.
"""
current = await session.get(model, service_id)
if current is None:
current = model(service_id=service_id)
session.add(current)
for k, v in data.items():
setattr(current, k, v)
async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: AsyncSession) -> None:
# Ensure svc.id is available for new rows.
await session.flush()
if body.third_party is not None:
await _upsert_ext(ServiceThirdParty, svc.id, body.third_party.model_dump(), session)
if body.cloud is not None:
await _upsert_ext(ServiceCloud, svc.id, body.cloud.model_dump(), session)
if body.self_hosted is not None:
await _upsert_ext(ServiceSelfHosted, svc.id, body.self_hosted.model_dump(), session)
if body.first_party is not None:
data = body.first_party.model_dump(exclude={"repo_slug"})
if body.first_party.repo_slug and not data.get("repo_id"):
repo = (await session.execute(
select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug)
)).scalar_one_or_none()
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found")
data["repo_id"] = repo.id
await _upsert_ext(ServiceFirstParty, svc.id, data, session)
__all__ = ["router"]

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,8 @@ from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task, TaskPriority, TaskStatus
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic, TopicStatus
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.decision import DecisionRead
from api.schemas.domain import DomainSummary
from api.schemas.progress_event import ProgressEventRead
@@ -43,38 +43,74 @@ from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.routers.workstreams import _workplan_index
from api.services.summary_cache import (
apply_progress_section,
fetch_summary_revision,
get_summary_cache,
register_summary_cache_invalidation,
)
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES,
OPEN_WORKSTREAM_STATUSES,
normalize_workstream_status,
CLOSED_WORKPLAN_STATUSES,
OPEN_WORKPLAN_STATUSES,
normalize_workplan_status,
)
from task_flow_engine import FlowEngine
router = APIRouter(prefix="/state", tags=["state"])
_SUMMARY_CACHE: StateSummary | None = None
_SUMMARY_CACHE_AT: float = 0.0
_SUMMARY_TTL = 15.0
_OVERVIEW_CACHE: DashboardOverview | None = None
_OVERVIEW_CACHE_AT: float = 0.0
_OVERVIEW_TTL = 10.0
def _summary_cache_headers(
response: Response,
*,
cache_status: str,
revision: str,
) -> None:
response.headers["X-StateHub-Cache"] = cache_status
response.headers["X-StateHub-Revision"] = revision
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
@router.get("/summary", response_model=StateSummary)
async def get_summary(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
refresh: bool = False,
) -> StateSummary:
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
no_cache = "no-cache" in request.headers.get("cache-control", "")
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
response.headers["X-StateHub-Cache"] = "hit"
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
return _SUMMARY_CACHE
response.headers["X-StateHub-Cache"] = "miss"
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=30"
revision = await fetch_summary_revision(session)
revision_token = revision.combined_fingerprint()
force_refresh = refresh or "no-cache" in request.headers.get("cache-control", "")
cache = get_summary_cache()
cache_status, cached = cache.resolve(revision, force_refresh=force_refresh)
if cache_status == "hit-revision" and cached is not None:
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return cached
if cache_status == "progress-section" and cached is not None:
result = await apply_progress_section(session, cached, revision)
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return result
if cache_status == "stale" and cached is not None:
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
return cached
result = await build_state_summary(session)
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
return result
async def build_state_summary(session: AsyncSession) -> StateSummary:
"""Build the full state summary snapshot (cache miss / forced refresh)."""
# Run all queries sequentially on one session.
# AsyncSession does not support concurrent operations (no gather on same session).
@@ -82,7 +118,7 @@ async def get_summary(
select(Topic)
.options(
selectinload(Topic.domain),
noload(Topic.workstreams),
noload(Topic.workplans),
noload(Topic.decisions),
noload(Topic.progress_events),
)
@@ -96,16 +132,16 @@ async def get_summary(
if topic_ids:
topic_ws_rows = await session.execute(
select(
Workstream.topic_id,
Workstream.id,
Workstream.slug,
Workstream.title,
Workstream.status,
Workstream.owner,
Workstream.due_date,
Workplan.topic_id,
Workplan.id,
Workplan.slug,
Workplan.title,
Workplan.status,
Workplan.owner,
Workplan.due_date,
)
.where(Workstream.topic_id.in_(topic_ids))
.order_by(Workstream.created_at)
.where(Workplan.topic_id.in_(topic_ids))
.order_by(Workplan.created_at)
)
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
topic_workstreams.setdefault(topic_id, []).append({
@@ -136,10 +172,10 @@ async def get_summary(
recent = list(recent_rows.scalars().all())
open_ws_rows = await session.execute(
select(Workstream)
select(Workplan)
.options(noload("*"))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -147,7 +183,7 @@ async def get_summary(
task_per_ws: dict = {}
task_statuses_per_ws: dict = {}
for ws_id, tstat, cnt in await session.execute(
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
):
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt)
@@ -157,9 +193,9 @@ async def get_summary(
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
@@ -168,16 +204,16 @@ async def get_summary(
dep_ws_ids = set()
dep_task_ids = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
dep_ws_ids.add(d.from_workplan_id)
if d.to_workplan_id:
dep_ws_ids.add(d.to_workplan_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
ws_lookup: dict = {w.id: w for w in open_ws}
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
extra_rows = await session.execute(
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
@@ -189,7 +225,7 @@ async def get_summary(
# Index: workstream_id → (depends_on stubs, blocks stubs)
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id,
@@ -230,9 +266,9 @@ async def get_summary(
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
for d in dep_rows
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
@@ -246,7 +282,7 @@ async def get_summary(
select(Topic.status, func.count()).group_by(Topic.status)
)}
ws_counts = {r[0]: r[1] for r in await session.execute(
select(Workstream.status, func.count()).group_by(Workstream.status)
select(Workplan.status, func.count()).group_by(Workplan.status)
)}
task_counts = {r[0]: r[1] for r in await session.execute(
select(Task.status, func.count()).group_by(Task.status)
@@ -370,11 +406,13 @@ async def get_summary(
for w in open_ws
],
)
_SUMMARY_CACHE = result
_SUMMARY_CACHE_AT = time.monotonic()
return result
get_summary_cache().configure(build_state_summary)
register_summary_cache_invalidation()
@router.get("/overview", response_model=DashboardOverview)
async def get_overview(
request: Request,
@@ -407,7 +445,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
select(Topic)
.options(
selectinload(Topic.domain),
noload(Topic.workstreams),
noload(Topic.workplans),
noload(Topic.decisions),
noload(Topic.progress_events),
)
@@ -418,12 +456,12 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
topic_map = {topic.id: topic for topic in topics}
workstream_rows = await session.execute(
select(Workstream)
select(Workplan)
.options(noload("*"))
.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
Workplan.planning_priority.asc().nullslast(),
Workplan.planning_order.asc().nullslast(),
Workplan.updated_at.desc(),
)
)
workstreams_all = list(workstream_rows.scalars().all())
@@ -455,7 +493,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
task_statuses_per_ws: dict = {}
task_totals_by_status: dict[str, int] = {}
for ws_id, task_status, count in await session.execute(
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
):
status = status_value(task_status)
task_counts_by_ws.setdefault(ws_id, {"done": 0, "progress": 0, "wait": 0, "todo": 0, "total": 0})
@@ -467,15 +505,15 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
open_ws = [
w for w in workstreams_all
if normalize_workstream_status(w.status) in OPEN_WORKSTREAM_STATUSES
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
]
open_ws_ids = [w.id for w in open_ws]
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
@@ -490,19 +528,19 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"dependencies": [
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
{"workstation": normalize_workplan_status(ws_lookup[d.to_workplan_id].status)}
for d in dep_rows
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
if d.from_workplan_id == w.id and d.to_workplan_id and d.to_workplan_id in ws_lookup
],
}
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workstream_status(w.status)
effective_status[w.id] = "blocked" if flow_result.exit_blocked else normalize_workplan_status(w.status)
topic_counts = {r[0]: r[1] for r in await session.execute(
select(Topic.status, func.count()).group_by(Topic.status)
)}
ws_counts = {r[0]: r[1] for r in await session.execute(
select(Workstream.status, func.count()).group_by(Workstream.status)
select(Workplan.status, func.count()).group_by(Workplan.status)
)}
dec_counts = {r[0]: r[1] for r in await session.execute(
select(Decision.status, func.count()).group_by(Decision.status)
@@ -631,7 +669,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
workplan_rows.append(DashboardWorkplanRow(
id=w.id,
title=w.title,
status=normalize_workstream_status(w.status),
status=normalize_workplan_status(w.status),
domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"),
repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"),
workplan_filename=workplan.get("filename"),
@@ -695,9 +733,9 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
# Active workstream counts per domain (join through topics)
ws_per_domain = {}
for domain_id, cnt in await session.execute(
select(Topic.domain_id, func.count(Workstream.id))
.join(Workstream, Workstream.topic_id == Topic.id)
.where(Workstream.status.in_(["active", "blocked"]))
select(Topic.domain_id, func.count(Workplan.id))
.join(Workplan, Workplan.topic_id == Topic.id)
.where(Workplan.status.in_(["active", "blocked"]))
.group_by(Topic.domain_id)
):
ws_per_domain[domain_id] = cnt
@@ -734,10 +772,10 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
Used by workstreams.md and dependencies.md which only need dep edges.
"""
open_ws_rows = await session.execute(
select(Workstream)
select(Workplan)
.options(noload("*"))
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
.where(Workplan.status.in_(OPEN_WORKPLAN_STATUSES))
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -745,9 +783,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
dep_rows = []
if open_ws_ids:
dep_result = await session.execute(
select(WorkstreamDependency).where(
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
select(WorkplanDependency).where(
(WorkplanDependency.from_workplan_id.in_(open_ws_ids))
| (WorkplanDependency.to_workplan_id.in_(open_ws_ids))
)
)
dep_rows = list(dep_result.scalars().all())
@@ -755,9 +793,9 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
dep_ws_ids: set = set()
dep_task_ids: set = set()
for d in dep_rows:
dep_ws_ids.add(d.from_workstream_id)
if d.to_workstream_id:
dep_ws_ids.add(d.to_workstream_id)
dep_ws_ids.add(d.from_workplan_id)
if d.to_workplan_id:
dep_ws_ids.add(d.to_workplan_id)
if d.to_task_id:
dep_task_ids.add(d.to_task_id)
@@ -765,7 +803,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
extra_ids = dep_ws_ids - set(ws_lookup.keys())
if extra_ids:
extra_rows = await session.execute(
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
select(Workplan).options(noload("*")).where(Workplan.id.in_(extra_ids))
)
for w in extra_rows.scalars():
ws_lookup[w.id] = w
@@ -777,7 +815,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
for d in dep_rows:
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
from_id, to_id, task_id = d.from_workplan_id, d.to_workplan_id, d.to_task_id
if from_id in dep_index and to_id and to_id in ws_lookup:
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
@@ -831,7 +869,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
.options(noload("*"))
.where(Decision.status == DecisionStatus.resolved)
.where(Decision.decided_at >= cutoff)
.where(Decision.workstream_id.isnot(None))
.where(Decision.workplan_id.isnot(None))
.order_by(Decision.decided_at.desc())
.limit(20)
)
@@ -839,7 +877,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
open_tasks_rows = await session.execute(
select(Task)
.options(noload("*"))
.where(Task.workstream_id == decision.workstream_id)
.where(Task.workplan_id == decision.workplan_id)
.where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait]))
)
open_tasks = list(open_tasks_rows.scalars().all())
@@ -848,7 +886,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
if task.id in seen_task_ids:
continue
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
domain_slug = await _get_domain_slug_for_workstream(ws, session)
steps.append(NextStep(
type="resolved_decision",
@@ -868,13 +906,13 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
# ── Signal 2: cleared dependencies ──────────────────────────────────────
all_dep_rows = await session.execute(
select(
WorkstreamDependency.from_workstream_id,
WorkstreamDependency.to_workstream_id,
).where(WorkstreamDependency.to_workstream_id.isnot(None))
WorkplanDependency.from_workplan_id,
WorkplanDependency.to_workplan_id,
).where(WorkplanDependency.to_workplan_id.isnot(None))
)
all_deps = all_dep_rows.all()
# Group from_workstream_id → set of to_workstream_ids
# Group from_workplan_id → set of to_workplan_ids
dep_map: dict = {}
dep_ws_ids = set()
for from_ws_id, to_ws_id in all_deps:
@@ -886,12 +924,12 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
if dep_ws_ids:
ws_rows = await session.execute(
select(
Workstream.id,
Workstream.status,
Workstream.title,
Workstream.slug,
Workstream.topic_id,
).where(Workstream.id.in_(dep_ws_ids))
Workplan.id,
Workplan.status,
Workplan.title,
Workplan.slug,
Workplan.topic_id,
).where(Workplan.id.in_(dep_ws_ids))
)
ws_info = {
ws_id: {
@@ -906,9 +944,9 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
ready_from_ws_ids = [
from_ws_id
for from_ws_id, to_ws_ids in dep_map.items()
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
if normalize_workplan_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKPLAN_STATUSES
and all(
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
normalize_workplan_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKPLAN_STATUSES
for to_id in to_ws_ids
)
]
@@ -918,11 +956,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
todo_rows = await session.execute(
select(Task)
.options(noload("*"))
.where(Task.workstream_id.in_(ready_from_ws_ids))
.where(Task.workplan_id.in_(ready_from_ws_ids))
.where(Task.status == TaskStatus.todo)
)
for task in todo_rows.scalars().all():
todo_by_ws.setdefault(task.workstream_id, []).append(task)
todo_by_ws.setdefault(task.workplan_id, []).append(task)
for from_ws_id in ready_from_ws_ids:
from_ws = ws_info.get(from_ws_id, {})
@@ -956,7 +994,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
return steps
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
async def _get_domain_slug_for_workstream(ws: Workplan | None, session: AsyncSession) -> str | None:
"""Get the domain slug for a workstream via its topic."""
if ws is None or ws.topic_id is None:
return None
@@ -986,10 +1024,9 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
@router.get("/health")
async def health_check() -> dict:
async def health_check(session: AsyncSession = Depends(get_session)) -> dict:
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
await session.execute(text("SELECT 1"))
return {"status": "ok", "db": "connected"}
except Exception as exc:
return JSONResponse(

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,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.managed_repo import ManagedRepo
from api.models.workstream import Workstream
from api.schemas.workstream import (
WorkstreamCreate,
WorkstreamRead,
WorkstreamUpdate,
from api.models.workplan import Workplan
from api.schemas.workplan import (
WorkplanCreate,
WorkplanRead,
WorkplanUpdate,
)
from api.services.lifecycle import transition_workstream_status
from api.services.lifecycle import transition_workplan_status
from api.services.legacy_meter import (
LegacyUsageIdentity,
identity_from_request,
record_legacy_usage,
)
from api.workplan_status import (
is_supported_workstream_status,
normalize_workstream_status,
is_supported_workplan_status,
normalize_workplan_status,
ready_review_status,
)
@@ -138,7 +138,7 @@ async def _meter_legacy_event(
logger.warning("legacy-meter failed to record event subject %s", subject, exc_info=True)
async def _list_workstreams(
async def _list_workplans(
*,
topic_id: uuid.UUID | None,
repo_id: uuid.UUID | None,
@@ -147,27 +147,27 @@ async def _list_workstreams(
owner: str | None,
slug: str | None,
session: AsyncSession,
) -> list[Workstream]:
q = select(Workstream)
) -> list[Workplan]:
q = select(Workplan)
if topic_id:
q = q.where(Workstream.topic_id == topic_id)
q = q.where(Workplan.topic_id == topic_id)
if repo_id:
q = q.where(Workstream.repo_id == repo_id)
q = q.where(Workplan.repo_id == repo_id)
if repo_goal_id:
q = q.where(Workstream.repo_goal_id == repo_goal_id)
q = q.where(Workplan.repo_goal_id == repo_goal_id)
if status_filter:
normalised_status = normalize_workstream_status(status_filter)
if not is_supported_workstream_status(status_filter):
normalised_status = normalize_workplan_status(status_filter)
if not is_supported_workplan_status(status_filter):
raise HTTPException(status_code=422, detail=f"Unsupported workplan status '{status_filter}'")
q = q.where(Workstream.status == normalised_status)
q = q.where(Workplan.status == normalised_status)
if owner:
q = q.where(Workstream.owner == owner)
q = q.where(Workplan.owner == owner)
if slug:
q = q.where(Workstream.slug == slug)
q = q.where(Workplan.slug == slug)
q = q.order_by(
Workstream.planning_priority.asc().nullslast(),
Workstream.planning_order.asc().nullslast(),
Workstream.updated_at.desc(),
Workplan.planning_priority.asc().nullslast(),
Workplan.planning_order.asc().nullslast(),
Workplan.updated_at.desc(),
)
result = await session.execute(q)
return list(result.scalars().all())
@@ -190,10 +190,10 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
continue
for path in sorted(directory.glob("*.md")):
data = _frontmatter(path)
workstream_id = data.get("state_hub_workstream_id")
if not workstream_id:
workplan_id = data.get("state_hub_workstream_id") or data.get("state_hub_workplan_id")
if not workplan_id:
continue
file_status = normalize_workstream_status(data.get("status", ""))
file_status = normalize_workplan_status(data.get("status", ""))
review = (
ready_review_status(
root,
@@ -203,7 +203,7 @@ async def _build_workplan_index(session: AsyncSession) -> dict[str, Any]:
if file_status == "ready"
else None
)
index[str(workstream_id)] = {
index[str(workplan_id)] = {
"filename": path.name,
"relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug,
@@ -287,79 +287,79 @@ async def _workplan_index(
return _INDEX_CACHE
async def _create_workstream(
async def _create_workplan(
*,
body: WorkstreamCreate,
body: WorkplanCreate,
session: AsyncSession,
) -> Workstream:
ws = Workstream(**body.model_dump())
session.add(ws)
) -> Workplan:
wp = Workplan(**body.model_dump())
session.add(wp)
await session.commit()
await session.refresh(ws)
return ws
await session.refresh(wp)
return wp
async def _get_workstream(
async def _get_workplan(
*,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
) -> Workplan:
wp = await session.get(Workplan, workplan_id)
if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found")
return ws
return wp
async def _update_workstream(
async def _update_workplan(
*,
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
workplan_id: uuid.UUID,
body: WorkplanUpdate,
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
) -> Workplan:
wp = await session.get(Workplan, workplan_id)
if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found")
update_data = body.model_dump(exclude_unset=True)
status_update = update_data.pop("status", None)
prev_status = ws.status
prev_status = wp.status
for field, value in update_data.items():
setattr(ws, field, value)
setattr(wp, field, value)
if status_update is not None:
transition_workstream_status(ws, status_update)
transition_workplan_status(wp, status_update)
await session.commit()
await session.refresh(ws)
await session.refresh(wp)
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
await _publish_completion_events(ws, session)
if normalize_workplan_status(prev_status) != "finished" and wp.status == "finished":
await _publish_completion_events(wp, session)
return ws
return wp
async def _archive_workstream(
async def _archive_workplan(
*,
workstream_id: uuid.UUID,
workplan_id: uuid.UUID,
session: AsyncSession,
) -> Workstream:
ws = await session.get(Workstream, workstream_id)
if ws is None:
) -> Workplan:
wp = await session.get(Workplan, workplan_id)
if wp is None:
raise HTTPException(status_code=404, detail="Workplan not found")
transition_workstream_status(ws, "archived")
transition_workplan_status(wp, "archived")
await session.commit()
await session.refresh(ws)
return ws
await session.refresh(wp)
return wp
async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> None:
async def _publish_completion_events(wp: Workplan, session: AsyncSession) -> None:
workplan_envelope = EventEnvelope.new(
_COMPLETED_WORKPLAN_EVENT,
attributes={
"workplan_id": str(ws.id),
"legacy_workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
"workplan_id": str(wp.id),
"legacy_workstream_id": str(wp.id),
"slug": wp.slug,
"title": wp.title,
"topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKPLAN_EVENT, workplan_envelope))
@@ -372,18 +372,18 @@ async def _publish_completion_events(ws: Workstream, session: AsyncSession) -> N
legacy_envelope = EventEnvelope.new(
_COMPLETED_WORKSTREAM_EVENT,
attributes={
"workstream_id": str(ws.id),
"slug": ws.slug,
"title": ws.title,
"topic_id": str(ws.topic_id),
"repo_id": str(ws.repo_id) if ws.repo_id else None,
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
"workstream_id": str(wp.id),
"slug": wp.slug,
"title": wp.title,
"topic_id": str(wp.topic_id) if wp.topic_id else None,
"repo_id": str(wp.repo_id) if wp.repo_id else None,
"repo_goal_id": str(wp.repo_goal_id) if wp.repo_goal_id else None,
},
)
asyncio.create_task(publish_event(_COMPLETED_WORKSTREAM_EVENT, legacy_envelope))
@router.get("/", response_model=list[WorkstreamRead])
@router.get("/", response_model=list[WorkplanRead])
async def list_workstreams(
request: Request,
response: Response,
@@ -394,7 +394,7 @@ async def list_workstreams(
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
) -> list[Workplan]:
await _meter_legacy_route(
session=session,
request=request,
@@ -402,7 +402,7 @@ async def list_workstreams(
interface_key=_legacy_key("GET", "/workstreams/"),
replacement_ref="/workplans/",
)
return await _list_workstreams(
return await _list_workplans(
topic_id=topic_id,
repo_id=repo_id,
repo_goal_id=repo_goal_id,
@@ -413,7 +413,7 @@ async def list_workstreams(
)
@workplan_router.get("/", response_model=list[WorkstreamRead])
@workplan_router.get("/", response_model=list[WorkplanRead])
async def list_workplans(
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
@@ -422,8 +422,8 @@ async def list_workplans(
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Workstream]:
return await _list_workstreams(
) -> list[Workplan]:
return await _list_workplans(
topic_id=topic_id,
repo_id=repo_id,
repo_goal_id=repo_goal_id,
@@ -459,13 +459,13 @@ async def workplan_index_preferred(
return await _workplan_index(refresh=refresh, session=session)
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
async def create_workstream(
request: Request,
response: Response,
body: WorkstreamCreate,
body: WorkplanCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -473,24 +473,24 @@ async def create_workstream(
interface_key=_legacy_key("POST", "/workstreams/"),
replacement_ref="/workplans/",
)
return await _create_workstream(body=body, session=session)
return await _create_workplan(body=body, session=session)
@workplan_router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
@workplan_router.post("/", response_model=WorkplanRead, status_code=status.HTTP_201_CREATED)
async def create_workplan(
body: WorkstreamCreate,
body: WorkplanCreate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _create_workstream(body=body, session=session)
) -> Workplan:
return await _create_workplan(body=body, session=session)
@router.get("/{workstream_id}", response_model=WorkstreamRead)
@router.get("/{workstream_id}", response_model=WorkplanRead)
async def get_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -498,25 +498,25 @@ async def get_workstream(
interface_key=_legacy_key("GET", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _get_workstream(workstream_id=workstream_id, session=session)
return await _get_workplan(workplan_id=workstream_id, session=session)
@workplan_router.get("/{workplan_id}", response_model=WorkstreamRead)
@workplan_router.get("/{workplan_id}", response_model=WorkplanRead)
async def get_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _get_workstream(workstream_id=workplan_id, session=session)
) -> Workplan:
return await _get_workplan(workplan_id=workplan_id, session=session)
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
@router.patch("/{workstream_id}", response_model=WorkplanRead)
async def update_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
body: WorkstreamUpdate,
body: WorkplanUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -524,25 +524,25 @@ async def update_workstream(
interface_key=_legacy_key("PATCH", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _update_workstream(workstream_id=workstream_id, body=body, session=session)
return await _update_workplan(workplan_id=workstream_id, body=body, session=session)
@workplan_router.patch("/{workplan_id}", response_model=WorkstreamRead)
@workplan_router.patch("/{workplan_id}", response_model=WorkplanRead)
async def update_workplan(
workplan_id: uuid.UUID,
body: WorkstreamUpdate,
body: WorkplanUpdate,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _update_workstream(workstream_id=workplan_id, body=body, session=session)
) -> Workplan:
return await _update_workplan(workplan_id=workplan_id, body=body, session=session)
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
@router.delete("/{workstream_id}", response_model=WorkplanRead)
async def archive_workstream(
request: Request,
response: Response,
workstream_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
) -> Workplan:
await _meter_legacy_route(
session=session,
request=request,
@@ -550,12 +550,12 @@ async def archive_workstream(
interface_key=_legacy_key("DELETE", "/workstreams/{workstream_id}"),
replacement_ref="/workplans/{workplan_id}",
)
return await _archive_workstream(workstream_id=workstream_id, session=session)
return await _archive_workplan(workplan_id=workstream_id, session=session)
@workplan_router.delete("/{workplan_id}", response_model=WorkstreamRead)
@workplan_router.delete("/{workplan_id}", response_model=WorkplanRead)
async def archive_workplan(
workplan_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> Workstream:
return await _archive_workstream(workstream_id=workplan_id, session=session)
) -> Workplan:
return await _archive_workplan(workplan_id=workplan_id, session=session)

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

@@ -0,0 +1,49 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class ConsistencySweepIssueSummary(BaseModel):
fail: int = 0
automation_error: int = 0
warn: int = 0
info: int = 0
class ConsistencySweepRepoResult(BaseModel):
repo_slug: str
repo_path: str
result: str
summary: ConsistencySweepIssueSummary
fixes_applied: list[str] = Field(default_factory=list)
class ConsistencySweepRemoteAllGenerate(BaseModel):
max_seconds: int = Field(
default=300,
ge=0,
le=3600,
description="Wall-clock budget for the remote-all sweep (0 disables)",
)
source: str = Field(
default="api",
description="Runner label stored on progress events (local-timer, activity-core, api)",
)
class ConsistencySweepRemoteAllRun(BaseModel):
started_at: datetime
completed_at: datetime
max_seconds: int
source: str
exit_code: int
automation_error: bool = False
lock_skipped: bool
repos_processed: list[ConsistencySweepRepoResult] = Field(default_factory=list)
skipped_clean: list[str] = Field(default_factory=list)
skipped_missing: list[str] = Field(default_factory=list)
skipped_budget: list[str] = Field(default_factory=list)
progress_event_id: uuid.UUID | None = None

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

116
api/schemas/service.py Normal file
View File

@@ -0,0 +1,116 @@
"""Schemas for the two-dimension service catalog (STATE-WP-0062)."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
# ── Extension read models ────────────────────────────────────────────────────
class ServiceThirdPartyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
pricing_model: str
upstream_packages: list | None = None
upstream_contacts: list | None = None
source_url: str | None = None
support_url: str | None = None
license: str | None = None
class ServiceFirstPartyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
repo_id: uuid.UUID | None = None
owning_domain: str | None = None
class ServiceCloudRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
gdpr_maturity: str
gdpr_notes: str | None = None
dpa_available: bool
tos_url: str | None = None
privacy_policy_url: str | None = None
data_processing_regions: list | None = None
data_retention_notes: str | None = None
class ServiceSelfHostedRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
helix_instance: str | None = None
host_node: str | None = None
deployment_ref: str | None = None
runbook_ref: str | None = None
upstream_oss_project: str | None = None
class ServiceCatalogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
slug: str
name: str
owner_or_provider: str | None = None
category: str | None = None
description: str | None = None
website_url: str | None = None
status: str
hosting_type: str
development_type: str
maturity_level: int | None = None
created_at: datetime
updated_at: datetime
third_party: ServiceThirdPartyRead | None = None
first_party: ServiceFirstPartyRead | None = None
cloud: ServiceCloudRead | None = None
self_hosted: ServiceSelfHostedRead | None = None
# ── Write (upsert) models ────────────────────────────────────────────────────
class ServiceThirdPartyIn(BaseModel):
pricing_model: str = "unknown"
upstream_packages: list | None = None
upstream_contacts: list | None = None
source_url: str | None = None
support_url: str | None = None
license: str | None = None
class ServiceFirstPartyIn(BaseModel):
repo_id: uuid.UUID | None = None
repo_slug: str | None = None
owning_domain: str | None = None
class ServiceCloudIn(BaseModel):
gdpr_maturity: str = "unknown"
gdpr_notes: str | None = None
dpa_available: bool = False
tos_url: str | None = None
privacy_policy_url: str | None = None
data_processing_regions: list | None = None
data_retention_notes: str | None = None
class ServiceSelfHostedIn(BaseModel):
helix_instance: str | None = None
host_node: str | None = None
deployment_ref: str | None = None
runbook_ref: str | None = None
upstream_oss_project: str | None = None
class ServiceUpsert(BaseModel):
slug: str
name: str
owner_or_provider: str | None = None
category: str | None = None
description: str | None = None
website_url: str | None = None
status: str = "active"
hosting_type: str # self_hosted | cloud_hosted
development_type: str # first_party | third_party
maturity_level: int | None = None
third_party: ServiceThirdPartyIn | None = None
first_party: ServiceFirstPartyIn | None = None
cloud: ServiceCloudIn | None = None
self_hosted: ServiceSelfHostedIn | None = None

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

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

@@ -0,0 +1,107 @@
import uuid
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from api.schemas.workplan_dependency import WorkplanDepStub
from api.workplan_status import normalize_workplan_status
WorkplanStatus = Literal[
"proposed",
"ready",
"active",
"blocked",
"backlog",
"finished",
"archived",
]
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
ConcurrencyMode = Literal["sequential", "parallel"]
class WorkplanStatusMixin(BaseModel):
@field_validator("status", mode="before", check_fields=False)
@classmethod
def _normalise_status(cls, value):
return normalize_workplan_status(value)
class WorkplanCreate(WorkplanStatusMixin):
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkplanStatus = "active"
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_goal_id: uuid.UUID | None = None
class WorkplanUpdate(WorkplanStatusMixin):
title: str | None = None
description: str | None = None
status: WorkplanStatus | None = None
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
topic_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
class WorkplanRead(WorkplanStatusMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkplanStatus
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
created_at: datetime
updated_at: datetime
class WorkplanWithTaskCounts(WorkplanRead):
tasks_total: int = 0
tasks_wait: int = 0
tasks_todo: int = 0
tasks_progress: int = 0
tasks_done: int = 0
tasks_cancel: int = 0
class WorkplanWithDeps(WorkplanWithTaskCounts):
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
depends_on: list[WorkplanDepStub] = []
blocks: list[WorkplanDepStub] = []
blocked_reasons: list[dict] = []

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

@@ -0,0 +1,215 @@
from __future__ import annotations
import asyncio
import json
import re
import subprocess
import sys
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.models.progress_event import ProgressEvent
from api.schemas.consistency_sweep import (
ConsistencySweepIssueSummary,
ConsistencySweepRemoteAllRun,
ConsistencySweepRepoResult,
)
_LOCK_SKIP_MARKER = "another fix-consistency-remote --all run is already active"
_CLEAN_RE = re.compile(r"^\s*CLEAN \(skipped\):\s*(.+)$", re.MULTILINE)
_MISSING_RE = re.compile(r"^\s*NOT ON THIS HOST \(skipped\):\s*(.+)$", re.MULTILINE)
_BUDGET_RE = re.compile(
r"^\s*BUDGET EXHAUSTED after \d+s \(skipped\):\s*(.+)$",
re.MULTILINE,
)
def _script_path() -> Path:
return Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
def _split_slug_list(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def _parse_stderr(stderr: str) -> dict[str, list[str]]:
return {
"skipped_clean": _split_slug_list(_CLEAN_RE.search(stderr).group(1))
if _CLEAN_RE.search(stderr)
else [],
"skipped_missing": _split_slug_list(_MISSING_RE.search(stderr).group(1))
if _MISSING_RE.search(stderr)
else [],
"skipped_budget": _split_slug_list(_BUDGET_RE.search(stderr).group(1))
if _BUDGET_RE.search(stderr)
else [],
}
def _extract_json_payload(text: str) -> Any:
stripped = text.strip()
if not stripped:
return []
decoder = json.JSONDecoder()
for index, char in enumerate(stripped):
if char not in "{[":
continue
try:
payload, _end = decoder.raw_decode(stripped, index)
return payload
except json.JSONDecodeError:
continue
raise json.JSONDecodeError("No JSON payload found", stripped, 0)
def _parse_stdout(stdout: str) -> list[ConsistencySweepRepoResult]:
text = stdout.strip()
if not text:
return []
payload = _extract_json_payload(text)
items = payload if isinstance(payload, list) else [payload]
results: list[ConsistencySweepRepoResult] = []
for item in items:
summary = item.get("summary") or {}
results.append(
ConsistencySweepRepoResult(
repo_slug=str(item.get("repo_slug") or ""),
repo_path=str(item.get("repo_path") or ""),
result=str(item.get("result") or "pass"),
summary=ConsistencySweepIssueSummary(
fail=int(summary.get("fail", 0)),
automation_error=int(summary.get("automation_error", 0)),
warn=int(summary.get("warn", 0)),
info=int(summary.get("info", 0)),
),
fixes_applied=list(item.get("fixes_applied") or []),
)
)
return results
async def run_remote_all_sweep(
session: AsyncSession,
*,
max_seconds: int,
source: str = "api",
) -> ConsistencySweepRemoteAllRun:
started_at = datetime.now(tz=UTC)
cmd = [
sys.executable,
str(_script_path()),
"--remote",
"--all",
"--json",
"--api-base",
settings.api_base,
"--max-seconds",
str(max_seconds),
]
result = await asyncio.to_thread(
subprocess.run,
cmd,
capture_output=True,
text=True,
)
completed_at = datetime.now(tz=UTC)
lock_skipped = _LOCK_SKIP_MARKER in result.stderr
stderr_meta = _parse_stderr(result.stderr)
repos_processed = [] if lock_skipped else _parse_stdout(result.stdout)
automation_error = result.returncode != 0 and not lock_skipped
progress_event_id = await _log_sweep_progress(
session,
started_at=started_at,
completed_at=completed_at,
max_seconds=max_seconds,
source=source,
exit_code=result.returncode,
automation_error=automation_error,
lock_skipped=lock_skipped,
repos_processed=repos_processed,
**stderr_meta,
)
return ConsistencySweepRemoteAllRun(
started_at=started_at,
completed_at=completed_at,
max_seconds=max_seconds,
source=source,
exit_code=result.returncode,
automation_error=automation_error,
lock_skipped=lock_skipped,
repos_processed=repos_processed,
skipped_clean=stderr_meta["skipped_clean"],
skipped_missing=stderr_meta["skipped_missing"],
skipped_budget=stderr_meta["skipped_budget"],
progress_event_id=progress_event_id,
)
async def _log_sweep_progress(
session: AsyncSession,
*,
started_at: datetime,
completed_at: datetime,
max_seconds: int,
source: str,
exit_code: int,
automation_error: bool,
lock_skipped: bool,
repos_processed: list[ConsistencySweepRepoResult],
skipped_clean: list[str],
skipped_missing: list[str],
skipped_budget: list[str],
) -> uuid.UUID:
processed_count = len(repos_processed)
error_count = sum(1 for repo in repos_processed if repo.result == "error")
assessment_fail_count = sum(1 for repo in repos_processed if repo.result == "fail")
warn_count = sum(1 for repo in repos_processed if repo.result == "warn")
if lock_skipped:
summary = "State Hub consistency sweep skipped: prior remote-all run still active"
elif automation_error:
summary = (
"State Hub consistency sweep automation error: "
f"exit_code={exit_code}, {processed_count} repos partially processed"
)
else:
summary = (
"State Hub consistency sweep completed: "
f"{processed_count} processed, {len(skipped_clean)} clean, "
f"{len(skipped_missing)} missing, {len(skipped_budget)} budget-skipped, "
f"{assessment_fail_count} assessment-fail, {error_count} automation-error, "
f"{warn_count} warned"
)
event = ProgressEvent(
event_type="consistency_sweep_remote_all",
summary=summary,
detail={
"started_at": _iso(started_at),
"completed_at": _iso(completed_at),
"max_seconds": max_seconds,
"source": source,
"exit_code": exit_code,
"automation_error": automation_error,
"assessment_failures": assessment_fail_count,
"automation_errors": error_count,
"lock_skipped": lock_skipped,
"repos_processed": [item.model_dump(mode="json") for item in repos_processed],
"skipped_clean": skipped_clean,
"skipped_missing": skipped_missing,
"skipped_budget": skipped_budget,
},
author="state-hub",
)
session.add(event)
await session.commit()
await session.refresh(event)
return event.id
def _iso(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")

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,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,221 @@
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from api.database import async_session_factory
from api.models.write_idempotency_key import WriteIdempotencyKey
IDEMPOTENCY_HEADER = b"idempotency-key"
REPLAY_HEADER = "X-StateHub-Idempotency-Replay"
CONFLICT_STATUS = 409
DEFAULT_IDEMPOTENCY_TTL_DAYS = 14
@dataclass(frozen=True)
class WriteRouteRule:
method: str
pattern: str
route_class: str
description: str
def matches(self, method: str, path: str) -> bool:
normalized = path.rstrip("/") or "/"
return self.method == method.upper() and re.fullmatch(self.pattern, normalized) is not None
WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/progress", "append", "append progress event"),
WriteRouteRule("POST", r"/messages", "append", "send agent message"),
WriteRouteRule("PATCH", r"/messages/[^/]+/read", "append", "mark known message read"),
WriteRouteRule("POST", r"/token-events", "append", "record token event"),
WriteRouteRule("POST", r"/token-events/upsert", "append", "upsert token event"),
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
)
def route_rule_for(method: str, path: str) -> WriteRouteRule | None:
for rule in WRITE_ROUTE_RULES:
if rule.matches(method, path):
return rule
return None
def route_class_for(method: str, path: str) -> str | None:
rule = route_rule_for(method, path)
return rule.route_class if rule else None
def canonical_request_hash(method: str, path: str, query_string: bytes, body: bytes) -> str:
try:
parsed: Any = json.loads(body.decode("utf-8")) if body else None
body_repr = json.dumps(parsed, sort_keys=True, separators=(",", ":"))
except (UnicodeDecodeError, json.JSONDecodeError):
body_repr = body.hex()
query = query_string.decode("utf-8", errors="replace")
seed = f"{method.upper()}\n{path}\n{query}\n{body_repr}".encode("utf-8")
return hashlib.sha256(seed).hexdigest()
def _header_value(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None:
lname = name.lower()
for key, value in headers:
if key.lower() == lname:
return value.decode("utf-8", errors="replace")
return None
async def _send_json_response(response: JSONResponse, scope: Scope, receive: Receive, send: Send) -> None:
await response(scope, receive, send)
class WriteIdempotencyMiddleware:
"""Replay exact duplicate write requests carrying Idempotency-Key.
The middleware is intentionally narrow: it only participates on the offline
relay allowlist. Non-allowlisted routes keep their normal behavior even if a
caller sends an Idempotency-Key header.
"""
def __init__(self, app: ASGIApp, *, ttl_days: int = DEFAULT_IDEMPOTENCY_TTL_DAYS) -> None:
self.app = app
self.ttl_days = ttl_days
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = str(scope.get("method", "")).upper()
path = str(scope.get("path", ""))
rule = route_rule_for(method, path)
headers = list(scope.get("headers") or [])
key = _header_value(headers, IDEMPOTENCY_HEADER)
if rule is None or not key:
await self.app(scope, receive, send)
return
body = await self._read_body(receive)
request_hash = canonical_request_hash(method, path, scope.get("query_string", b""), body)
source_host = _header_value(headers, b"x-statehub-source-host")
source_agent = _header_value(headers, b"x-statehub-source-agent")
async with async_session_factory() as session:
existing = (await session.execute(
select(WriteIdempotencyKey).where(WriteIdempotencyKey.key == key)
)).scalar_one_or_none()
if existing is not None:
existing.last_seen_at = datetime.now(tz=timezone.utc)
await session.commit()
if existing.request_hash != request_hash:
await _send_json_response(
JSONResponse(
status_code=CONFLICT_STATUS,
content={
"error": "Idempotency-Key was reused with a different request",
"idempotency_key": key,
},
),
scope,
self._receive_from_body(body),
send,
)
return
await _send_json_response(
JSONResponse(
status_code=existing.response_status,
content=existing.response_body,
headers={REPLAY_HEADER: "true"},
),
scope,
self._receive_from_body(body),
send,
)
return
start_message: Message | None = None
body_parts: list[bytes] = []
async def capture_send(message: Message) -> None:
nonlocal start_message
if message["type"] == "http.response.start":
start_message = message
elif message["type"] == "http.response.body":
body_parts.append(message.get("body", b""))
await send(message)
await self.app(scope, self._receive_from_body(body), capture_send)
if start_message is None:
return
status = int(start_message.get("status", 500))
if status < 200 or status >= 300:
return
response_body_bytes = b"".join(body_parts)
try:
response_body = json.loads(response_body_bytes.decode("utf-8")) if response_body_bytes else None
except (UnicodeDecodeError, json.JSONDecodeError):
return
async with async_session_factory() as session:
existing = (await session.execute(
select(WriteIdempotencyKey).where(WriteIdempotencyKey.key == key)
)).scalar_one_or_none()
if existing is not None:
return
now = datetime.now(tz=timezone.utc)
session.add(WriteIdempotencyKey(
key=key,
method=method,
path=path,
route_class=rule.route_class,
request_hash=request_hash,
response_status=status,
response_body=response_body,
source_host=source_host,
source_agent=source_agent,
first_seen_at=now,
last_seen_at=now,
expires_at=now + timedelta(days=self.ttl_days),
))
await session.commit()
@staticmethod
async def _read_body(receive: Receive) -> bytes:
chunks: list[bytes] = []
while True:
message = await receive()
if message["type"] != "http.request":
continue
chunks.append(message.get("body", b""))
if not message.get("more_body", False):
break
return b"".join(chunks)
@staticmethod
def _receive_from_body(body: bytes) -> Receive:
sent = False
async def receive() -> Message:
nonlocal sent
if sent:
return {"type": "http.request", "body": b"", "more_body": False}
sent = True
return {"type": "http.request", "body": body, "more_body": False}
return receive

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 ─────────────────────────────────────
@@ -466,6 +465,55 @@ def cmd_status(_args: argparse.Namespace) -> None:
print(f" [{deadline}] {d['title']}")
def _outbox_store(args):
from api.edge.outbox import OutboxStore, default_outbox_path
return OutboxStore(args.outbox_path or default_outbox_path())
def cmd_outbox_status(args: argparse.Namespace) -> None:
store = _outbox_store(args)
print(json.dumps(store.summary(), indent=2))
def cmd_outbox_list(args: argparse.Namespace) -> None:
store = _outbox_store(args)
rows = store.export(status=args.status, limit=args.limit)
print(json.dumps(rows, indent=2))
def cmd_outbox_export(args: argparse.Namespace) -> None:
store = _outbox_store(args)
payload = store.export(status=args.status, limit=args.limit)
if args.output:
Path(args.output).write_text(json.dumps(payload, indent=2) + "\n")
print(f"Exported {len(payload)} envelope(s) to {args.output}")
else:
print(json.dumps(payload, indent=2))
def cmd_outbox_replay(args: argparse.Namespace) -> None:
import asyncio
from api.edge.relay import replay_pending
store = _outbox_store(args)
upstream = args.upstream_url or os.environ.get("STATEHUB_UPSTREAM_URL") or API_BASE
result = asyncio.run(replay_pending(store, upstream_url=upstream, limit=args.limit))
print(json.dumps(result, indent=2))
def cmd_outbox_retry(args: argparse.Namespace) -> None:
store = _outbox_store(args)
store.retry(args.envelope_id)
print(f"Queued {args.envelope_id} for retry")
def cmd_outbox_cancel(args: argparse.Namespace) -> None:
store = _outbox_store(args)
store.cancel(args.envelope_id)
print(f"Cancelled {args.envelope_id}")
# ── Entry point ────────────────────────────────────────────────────────────────
def main() -> None:
@@ -550,12 +598,47 @@ def main() -> None:
ctask.add_argument("--assignee", default=None)
ctask.add_argument("--description", default=None)
# outbox
outbox = sub.add_parser("outbox", help="Inspect and replay the local State Hub edge outbox")
outbox.add_argument("--outbox-path", default=None, help="SQLite outbox path (defaults to ~/.statehub/edge-outbox.sqlite3)")
out_sub = outbox.add_subparsers(dest="outbox_command", required=True)
out_status = out_sub.add_parser("status", help="Show pending, conflict, and ack counts")
out_status.set_defaults(func=cmd_outbox_status)
out_list = out_sub.add_parser("list", help="List outbox envelopes as JSON")
out_list.add_argument("--status", default=None, help="Filter by status")
out_list.add_argument("--limit", type=int, default=100)
out_list.set_defaults(func=cmd_outbox_list)
out_export = out_sub.add_parser("export", help="Export non-secret envelopes")
out_export.add_argument("--status", default=None, help="Filter by status")
out_export.add_argument("--limit", type=int, default=1000)
out_export.add_argument("--output", default=None, help="Write JSON to a file instead of stdout")
out_export.set_defaults(func=cmd_outbox_export)
out_replay = out_sub.add_parser("replay", help="Replay due queued envelopes")
out_replay.add_argument("--upstream-url", default=None, help="Central State Hub API base URL")
out_replay.add_argument("--limit", type=int, default=50)
out_replay.set_defaults(func=cmd_outbox_replay)
out_retry = out_sub.add_parser("retry", help="Force one envelope back to queued")
out_retry.add_argument("envelope_id")
out_retry.set_defaults(func=cmd_outbox_retry)
out_cancel = out_sub.add_parser("cancel", help="Cancel one envelope")
out_cancel.add_argument("envelope_id")
out_cancel.set_defaults(func=cmd_outbox_cancel)
# status
sub.add_parser("status", help="Show State Hub health and summary totals")
args = parser.parse_args()
if args.command == "register":
if hasattr(args, "func"):
args.func(args)
elif args.command == "register":
run_statehub_register(args)
elif args.command == "register-project":
cmd_register(args)

View File

@@ -34,7 +34,16 @@ export default {
{ name: "Inbox", path: "/inbox" },
{ name: "Progress", path: "/progress" },
{ name: "Token Cost", path: "/token-cost" },
{ name: "Services (TPSC)", path: "/tpsc" },
{
name: "Services",
collapsible: true,
open: false,
pages: [
{ name: "Third Party", path: "/tpsc" },
{ name: "First Party", path: "/services/first-party" },
{ name: "Self Hosted", path: "/services/self-hosted" },
],
},
{ name: "Todo", path: "/todo" },
{ name: "Tools & Apps", path: "/tools" },
// ── Sections (alphabetical) ───────────────────────────────────────────────
@@ -104,6 +113,7 @@ export default {
{ name: "Repos", path: "/docs/repos" },
{ name: "SBOM", path: "/docs/sbom" },
{ name: "SCOPE.md", path: "/docs/scope" },
{ name: "Service Catalog", path: "/docs/services" },
{ name: "Tasks", path: "/docs/tasks" },
{ name: "TPSC", path: "/docs/tpsc" },
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },

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
make fix-consistency REPO=<slug>
```

View File

@@ -0,0 +1,54 @@
---
title: Service Catalog — Reference
---
# Service Catalog (two dimensions)
Every service coulomb consumes or operates is classified along **two independent
dimensions**, so four classes fall out of their product:
| | **third-party** (not dev-responsible) | **first-party** (dev-responsible) |
|---|---|---|
| **cloud-hosted** (consumed) | SaaS / APIs — the classic [TPSC](/docs/tpsc) | a coulomb service deployed to a cloud |
| **self-hosted** (operated) | OSS coulomb runs (Gitea, Postgres…) | a coulomb service on coulomb infra |
- **Hosting** — `self_hosted` (coulomb operates the service) vs `cloud_hosted`
(coulomb consumes someone else's running service).
- **Development** — `first_party` (coulomb is development-responsible) vs
`third_party` (coulomb is not).
## Persistence
A common **`service_catalog`** core table holds the shared fields
(`slug`, `name`, `owner_or_provider`, `category`, `status`, `hosting_type`,
`development_type`, `maturity_level`). Dimension-specific data lives in 1:1
extension tables that **compose** — a self-hosted first-party service carries
both the self-hosted *and* first-party extensions:
| Extension | Keyed on | Holds |
|---|---|---|
| `service_third_party` | `development_type = third_party` | upstream packages, support/service contacts, source, license, pricing |
| `service_first_party` | `development_type = first_party` | internal dev repo (`managed_repos` FK), owning domain |
| `service_cloud` | `hosting_type = cloud_hosted` | GDPR maturity, DPA, ToS/privacy, data-processing regions, retention |
| `service_self_hosted` | `hosting_type = self_hosted` | three-helix instance/host, deployment & runbook refs, upstream OSS project |
`maturity_level` (1 · Core → 2 · Standard → 3 · Mature) tracks a service against
the [Service DoM](/policy/service-dom).
## API
- `GET /services/catalog?hosting_type=&development_type=&maturity_level=&status=`
— filtered list; each row includes its applicable extensions.
- `GET /services/{slug}` — one service with extensions.
- `POST /services/catalog` — upsert by slug; pass `first_party.repo_slug` to link
the internal dev repo.
The dashboard **Services** section renders three views over this catalog:
[Third Party](/tpsc), [First Party](/services/first-party), and
[Self Hosted](/services/self-hosted).
## Migration & back-compat
Existing TPSC catalog rows migrated into `service_catalog` as
`(cloud_hosted, third_party)`, reusing their ids so `tpsc_entries.catalog_id`
keep resolving. The `/tpsc/*` endpoints and `tpsc.yaml` ingestion are unchanged.

View File

@@ -7,6 +7,11 @@ title: Third-Party Services Catalog (TPSC)
The TPSC tracks external service dependencies (APIs, SaaS, CLIs) across all
registered repos — complementing the SBOM for package dependencies.
> **Now part of the broader service catalog.** TPSC is the `cloud_hosted` +
> `third_party` quadrant of the two-dimension [service catalog](/docs/services).
> Catalog rows have migrated into `service_catalog`; the `/tpsc/*` endpoints and
> per-repo `tpsc.yaml` dependency snapshots continue to work unchanged.
---
## Why TPSC?

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,49 @@
---
title: First Party Services
---
# First Party Services Catalog
Services **coulomb is development-responsible for** (`development_type = first_party`),
whether deployed to a cloud or self-hosted on coulomb infrastructure. The
**Service Maturity Level** column tracks each service against the
[Service DoM](/policy/service-dom) (1 · Core → 2 · Standard → 3 · Mature).
```js
import {API} from "../components/config.js";
```
```js
const services = await fetch(`${API}/services/catalog?development_type=first_party`)
.then(r => r.ok ? r.json() : [])
.catch(() => []);
const repos = await fetch(`${API}/repos/`)
.then(r => r.ok ? r.json() : [])
.catch(() => []);
const repoById = new Map(repos.map(r => [r.id, r.slug]));
```
```js
const LEVEL = {1: "1 · Core", 2: "2 · Standard", 3: "3 · Mature"};
const rows = services.map(s => ({
Service: s.name,
Slug: s.slug,
Hosting: s.hosting_type === "self_hosted" ? "self-hosted" : "cloud-hosted",
"Maturity Level": s.maturity_level ? LEVEL[s.maturity_level] : "—",
"Dev Repo": s.first_party?.repo_id ? (repoById.get(s.first_party.repo_id) ?? "(unlinked)") : "—",
Domain: s.first_party?.owning_domain ?? "—",
Status: s.status,
}));
```
```js
display(services.length === 0
? html`<div style="color:#64748b;padding:1rem;">No first-party services registered yet. Add one with
<code>POST /services/catalog</code> (<code>development_type: "first_party"</code>).</div>`
: Inputs.table(rows, {
columns: ["Service", "Hosting", "Maturity Level", "Dev Repo", "Domain", "Status"],
sort: "Service",
rows: 30,
}));
```

View File

@@ -0,0 +1,48 @@
---
title: Self Hosted Services
---
# Self Hosted Services Catalog
Services and webapps built on **third-party / open-source software** that coulomb
**hosts and operates** as part of the three-helix infrastructure
(`hosting_type = self_hosted`, `development_type = third_party`). coulomb runs
these but is not development-responsible for them.
> First-party services that coulomb also self-hosts (e.g. the State Hub itself)
> are listed under [First Party](/services/first-party), classified by who develops
> them.
```js
import {API} from "../components/config.js";
```
```js
const services = await fetch(`${API}/services/catalog?hosting_type=self_hosted&development_type=third_party`)
.then(r => r.ok ? r.json() : [])
.catch(() => []);
```
```js
const rows = services.map(s => ({
Service: s.name,
Slug: s.slug,
"Upstream OSS": s.self_hosted?.upstream_oss_project ?? s.owner_or_provider ?? "—",
"Helix Instance": s.self_hosted?.helix_instance ?? "—",
Host: s.self_hosted?.host_node ?? "—",
Runbook: s.self_hosted?.runbook_ref ?? "—",
Status: s.status,
}));
```
```js
display(services.length === 0
? html`<div style="color:#64748b;padding:1rem;">No self-hosted third-party services registered yet. Add one with
<code>POST /services/catalog</code> (<code>hosting_type: "self_hosted"</code>,
<code>development_type: "third_party"</code>).</div>`
: Inputs.table(rows, {
columns: ["Service", "Upstream OSS", "Helix Instance", "Host", "Runbook", "Status"],
sort: "Service",
rows: 30,
}));
```

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:b536741
```
railiance01 has already pulled this tag with `crictl`, and the image serves
`GET /state/health` against the local WSL database in smoke testing.
## Render And Dry-Run
Render the app chart without touching the cluster:
```bash
make railiance-state-hub-render
```
Run client-side Kubernetes validation for the platform manifests, app Secret
template, and rendered chart:
```bash
make railiance-state-hub-client-dry-run
```
Run server-side dry-run against the configured representative cluster:
```bash
KUBECONFIG=~/.kube/config-hosteurope make railiance-state-hub-server-dry-run
```
Server-side dry-run requires the CNPG CRDs, namespace permissions, and dry-run
permission for resources in `databases` and `state-hub`.
Before the `state-hub` namespace exists, Kubernetes cannot server-dry-run namespaced app
objects into that namespace because dry-run Namespace creation is not persisted.
The Make target therefore server-validates the platform and Namespace manifests,
then falls back to client dry-run for namespaced app manifests with an explicit
notice.
## Promotion Notes
Platform promotion into `railiance-platform`:
- copy `platform/state-hub-db-credentials.sops.yaml.template` to a real SOPS
secret file with an operator-generated password;
- apply or GitOps-manage `platform/state-hub-db-cluster.yaml`;
- apply or GitOps-manage `platform/state-hub-db-networkpolicies.yaml`.
App promotion into `railiance-apps`:
- copy `apps/charts/state-hub/` to `charts/state-hub/`;
- copy `apps/helm/state-hub-values.yaml` to `helm/state-hub-values.yaml`;
- apply or GitOps-manage `apps/manifests/state-hub-namespace.yaml`;
- create `state-hub-env` in the `state-hub` namespace from the approved
secret-delivery path;
- deploy with Helm using the production values file, which sets
`namespace.create=false`, only after `state-hub-db` is healthy.
## Runtime Secret Contract
The app chart expects a Kubernetes Secret named `state-hub-env` in the
`state-hub` namespace with at least:
```text
DATABASE_URL=postgresql+asyncpg://state_hub:<url-encoded-password>@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub
```
Optional runtime settings such as `CORS_ORIGINS` can live in the chart
ConfigMap. The default chart keeps public ingress disabled; access should use
the existing private tunnel/ops-bridge path until a separate exposure decision
is recorded.

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: "b536741"

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: "b536741"
ingress:
enabled: false

View File

@@ -0,0 +1,18 @@
# Template for the State Hub runtime Secret in the state-hub namespace.
# DO NOT commit this file with real credentials.
# Encrypt with: sops -e -i state-hub-env.sops.yaml
# Apply with: kubectl apply -f <(sops -d state-hub-env.sops.yaml)
---
apiVersion: v1
kind: Secret
metadata:
name: state-hub-env
namespace: state-hub
labels:
app.kubernetes.io/name: state-hub
app.kubernetes.io/component: runtime-env
app.kubernetes.io/managed-by: manual
railiance.io/layer: s5-app
type: Opaque
stringData:
DATABASE_URL: postgresql+asyncpg://state_hub:REPLACE_WITH_URL_ENCODED_PASSWORD@state-hub-db-rw.databases.svc.cluster.local:5432/state_hub

View File

@@ -0,0 +1,8 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: state-hub
labels:
railiance.io/layer: s5-app
railiance.io/postgres-client: state-hub-db

View File

@@ -0,0 +1,28 @@
---
# Dedicated CNPG Cluster for State Hub episodic memory.
# Owned by railiance-platform (S3). Operator lives in cnpg-system.
#
# Pre-condition: state-hub-db-credentials Secret exists in databases namespace.
# Runtime app Secret is separate and lives in the state-hub namespace.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: state-hub-db
namespace: databases
labels:
app.kubernetes.io/name: state-hub-db
app.kubernetes.io/component: database
app.kubernetes.io/managed-by: manual
railiance.io/layer: s3-platform
railiance.io/role: state-hub-database
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
size: 10Gi
bootstrap:
initdb:
database: state_hub
owner: state_hub
secret:
name: state-hub-db-credentials

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