Compare commits
323 commits
state-wp-0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b434deafc7 | |||
| 1a3375094f | |||
| a523f7ab76 | |||
| ddc3338541 | |||
| ea11451e9c | |||
| 747c91df4c | |||
| b0db0145be | |||
| 23f2038c5b | |||
|
|
8a9542ba9b | ||
| 261a48772c | |||
|
|
7a4f58b90c | ||
| b0063abe18 | |||
| b5920d85e3 | |||
| 281d24328c | |||
|
|
0747b4d95b | ||
| f767a1a245 | |||
| ccb285fc40 | |||
|
|
5944ea3c20 | ||
| fe6b8d96c2 | |||
| 022cf4b727 | |||
| 29428bb304 | |||
| 2cae49fa58 | |||
| 49e3182332 | |||
|
|
74c687c6bc | ||
| e96ef197cf | |||
|
|
485275d868 | ||
| ccf691042d | |||
|
|
2c60e5bcf7 | ||
| f04de759a1 | |||
|
|
74a3b22c05 | ||
| 3578dc2f32 | |||
| 4cef58cd98 | |||
| 0b44cf12f5 | |||
| 87047faa8e | |||
|
|
bec702d842 | ||
| e663f209f7 | |||
| 803bb95e1d | |||
| cdff3b7e08 | |||
| cf40c7bb4e | |||
| 903a2fb090 | |||
| 0e35f84ad5 | |||
| da30ce6723 | |||
| 8f0667d8ff | |||
|
|
bb319fea23 | ||
| d8e1810359 | |||
| 5dd04dcb3d | |||
| 667ed28037 | |||
| a7c91a6d65 | |||
| 142c94287e | |||
|
|
306a6e0ce8 | ||
| 566d21f3bf | |||
|
|
7df0dec715 | ||
| a32112e5ab | |||
| ddce470944 | |||
| 14e865ab30 | |||
| f39612558d | |||
| b9c87d1769 | |||
| 151fdcf397 | |||
|
|
6f64f1aab8 | ||
| fc8ec49d9e | |||
| 2343574a0c | |||
| c5e15c7d7f | |||
| b97cfbb2b1 | |||
|
|
e2fc488c40 | ||
| 8e36b22e26 | |||
| 34f5cb3fc3 | |||
| a65cef02cf | |||
| 41b0b24195 | |||
| f7402bd711 | |||
|
|
0ec5b9a98d | ||
| 9f0a104b56 | |||
| 037c8360e2 | |||
| ef0b6df3b8 | |||
|
|
bdf3a1fdf8 | ||
| 6b82215ae7 | |||
|
|
6312db8700 | ||
| 2e2ae1e5d0 | |||
|
|
639b9aed08 | ||
| 82ea38b180 | |||
|
|
9e77a4a9a2 | ||
| 8988a093f2 | |||
|
|
13f7a10018 | ||
| 0020286bab | |||
| 70dddf56f8 | |||
| 54b09ee9e2 | |||
| 43e3edd727 | |||
| 8754983a0d | |||
|
|
80daf659bb | ||
|
|
136256fa3d | ||
| 3204baf8fd | |||
|
|
bb1b8f4c95 | ||
|
|
47d6989bc5 | ||
| b08a5d43a0 | |||
|
|
c835be5ea2 | ||
|
|
41d80429a7 | ||
| 9c8b079a70 | |||
| 500049fc31 | |||
| fadfddb6c6 | |||
| 218f7fd8b5 | |||
| 1af9ec2f0c | |||
| 5686b79d8c | |||
| 95672e0c47 | |||
| 702578cca4 | |||
| d1ace1295f | |||
| 3c884dd3f9 | |||
| 240627937d | |||
| b43a1e5728 | |||
| 034eda9287 | |||
| d4ca94198e | |||
| b53daf648a | |||
| 7a21fef0e4 | |||
| 95e30188e6 | |||
| 70c585bb39 | |||
|
|
4b121f3796 | ||
| 0ed4984706 | |||
| ccf4959423 | |||
| 612476b536 | |||
| b2efadf254 | |||
| 4f68c5609f | |||
| 5ae1f4ffc5 | |||
| b140929dba | |||
| 11f689d86d | |||
|
|
550a523435 | ||
| 470ece82ed | |||
| ab6438235e | |||
|
|
59099c4043 | ||
| 929bc29513 | |||
| 85181cd3e4 | |||
|
|
8b38f815e5 | ||
|
|
12be827331 | ||
| 78f4aaeb8e | |||
| fc814bd094 | |||
| a6cab728cb | |||
| 52da6a17cb | |||
| 12d8a1d018 | |||
| 4991ac79ed | |||
| 6c1262ef6e | |||
| 5d09e8f5b0 | |||
| 532583ce17 | |||
| 76a7c7ed24 | |||
| 7c45534115 | |||
| 3f7307f665 | |||
| 5e4d0be31c | |||
| 8404ab32f4 | |||
| b47dd52c90 | |||
| 6606660632 | |||
| fb681b58f8 | |||
| 2c2d610b3b | |||
| 8386a0b81a | |||
| 7f41d42488 | |||
| b1cf1a3d97 | |||
| cdd5cef373 | |||
| de853fc9e6 | |||
| 8b207a991a | |||
| 43ffe883c3 | |||
| 6bb1fe823a | |||
| e8445e99b6 | |||
| 97c8762a71 | |||
| 89ff2b2ea3 | |||
| fd0d0d537b | |||
| 6390b7bead | |||
|
|
b02f3c22ad | ||
| 3cb256ef5c | |||
| 2b2016f1df | |||
| 13ada219eb | |||
|
|
83fc8c43f5 | ||
| 2e64fe3f71 | |||
| c8b4a67bc3 | |||
| 0839e308d1 | |||
| 4253998cd2 | |||
| 5c73cd32b3 | |||
| 56287708ca | |||
| 19fab26746 | |||
| b28493a676 | |||
| 4aeb7308e6 | |||
| ac21accd7a | |||
| 05dda8d276 | |||
| 0fc955989a | |||
| 82212165f9 | |||
| 6d04544368 | |||
| 4e68176492 | |||
|
|
9bbe9e38ae | ||
| a05a64bf0e | |||
| 284e28112d | |||
| fae2816199 | |||
|
|
b91b8705d1 | ||
| 57c3e08103 | |||
|
|
fb79d0d68d | ||
| b0e1af24f9 | |||
|
|
c43266f626 | ||
| 2d114be84d | |||
| c62e511de8 | |||
|
|
0007ca232b | ||
| 598f6418e7 | |||
| 52aefe39e4 | |||
| 76e6eda086 | |||
| e1e259cf87 | |||
| b75234a533 | |||
| d01ae3971d | |||
| 98518091ae | |||
| 1e5222d458 | |||
| 5fc4c56215 | |||
|
|
9e7e91300f | ||
|
|
ccff2385b8 | ||
| ceab9ce279 | |||
| 70a708ff11 | |||
| e2544b218d | |||
| 059de9358e | |||
| 03c7924b7f | |||
| 52a7d6bb73 | |||
|
|
6b439ddd06 | ||
| 697dd49390 | |||
| cb1b028fd1 | |||
| ce52e9d1e2 | |||
|
|
7e82dd4594 | ||
| 81861d816b | |||
| b9d9ffed5f | |||
|
|
ec780c05a2 | ||
| 31fe49fa84 | |||
|
|
853a2259bb | ||
| 3f1f03076f | |||
| a311ac3d06 | |||
| 433e104909 | |||
| 30b17adde1 | |||
| de58a0cf90 | |||
| 87f1296714 | |||
| 2841bf371c | |||
| 9dc1affa21 | |||
| 90824ca249 | |||
| 190bb41b89 | |||
| 7adfe1369f | |||
| 67c56e225c | |||
| a824fd5dfe | |||
| 5f747fbc1e | |||
| fb363b37d3 | |||
| 98af485cd5 | |||
| ecc10b1f9f | |||
|
|
133276daaf | ||
| 64b71320e7 | |||
| 93e95f93e7 | |||
| be7f2632c3 | |||
|
|
5fe38fa9d8 | ||
| 613477b4d2 | |||
|
|
7b2b9d03d9 | ||
| 1655b34fcd | |||
| fb45fdae46 | |||
| 05dba43cae | |||
|
|
908deddc9b | ||
|
|
69c54456f6 | ||
| 523751199a | |||
|
|
dac8ffe0af | ||
|
|
3e9ee0d94b | ||
| a301de0c02 | |||
|
|
30ea4cf791 | ||
|
|
b606d5d44c | ||
| 285dcc1c8f | |||
|
|
8bb75b001b | ||
| 391647ca8b | |||
|
|
31525650e6 | ||
| d8e0eddb22 | |||
|
|
71ad6c5d17 | ||
|
|
b320655640 | ||
| f564e99a14 | |||
|
|
131b824dd3 | ||
|
|
0b7819ed0b | ||
|
|
8e72f736bb | ||
|
|
03af9085be | ||
| 6d038fd050 | |||
|
|
aa45c9b6b0 | ||
| d327b2eaea | |||
| c40eb08e8b | |||
| 30a4133809 | |||
|
|
1f15ab8a90 | ||
|
|
ff909e104e | ||
|
|
dddca9c6e2 | ||
|
|
73072e5058 | ||
| ddd6a66730 | |||
| 71c94c940a | |||
| cb6eddf92b | |||
|
|
e5a083f444 | ||
| bfaff50b4e | |||
| 462f4a47d2 | |||
| 5927591be8 | |||
|
|
2217bdd9f5 | ||
| 477ad73149 | |||
| f5f2fe98da | |||
|
|
a8875868a1 | ||
|
|
8e6bfc56a3 | ||
| 72c8f80b1f | |||
| c113605fc0 | |||
|
|
91d157821b | ||
| fe4cfe22c9 | |||
|
|
d356fde41c | ||
|
|
42a2a702db | ||
| f4ec935c42 | |||
|
|
e6ce49212a | ||
| 3008d9e2f0 | |||
|
|
5a392385bc | ||
| d8a2bb5c6b | |||
|
|
21d576c96f | ||
|
|
96d036ce77 | ||
|
|
b7e2dbb67d | ||
| f8bd74e27e | |||
| b5747d1104 | |||
| c363549388 | |||
| 224584d379 | |||
| 6c4fc64ef3 | |||
| b564ac7046 | |||
| 3dbbc753bc | |||
| aade470f4a | |||
| 88ba666c95 | |||
| 4541f1d6fc | |||
| 59770145fd | |||
| 814595c317 | |||
| 451f02c1d5 | |||
| 1906289997 | |||
|
|
4d2898e2e2 | ||
| 92b23f2534 | |||
|
|
739d76fb58 | ||
| ac26bf2e66 | |||
| df19c464ff | |||
|
|
d15a9928b3 | ||
| b59cff508a |
325 changed files with 111010 additions and 1394 deletions
|
|
@ -42,6 +42,14 @@ ls workplans/
|
|||
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||
`wait`/`todo`/`progress` tasks.
|
||||
|
||||
Optional quality debt (STATE-WP-0077):
|
||||
```bash
|
||||
statehub quality-debt --repo-path .
|
||||
```
|
||||
Prefer **DoR-Ok** before heavy implementation on ready plans; **DoC-Ok** before
|
||||
confident intake promote; record with `quality_dor` / `quality_doc` /
|
||||
`quality_dod` fields — see `docs/work-record-quality-gates.md`.
|
||||
|
||||
**Step 4 — Present brief**
|
||||
|
||||
1. **Active workplans** for `infotech` — title, task counts, blocking decisions
|
||||
|
|
|
|||
|
|
@ -2,25 +2,25 @@
|
|||
# Custodian Brief — state-hub
|
||||
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-07-10 13:22 UTC
|
||||
**Last synced:** 2026-09-14 08:15 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### Workplan terminology phase-2 legacy retirement
|
||||
Progress: 2/5 done | workplan_id: `9aa92529-6ee8-4b3f-b573-78b0ef8d9788`
|
||||
### State Hub retirement strangler and disposition execution
|
||||
Progress: 4/9 done | workplan_id: `ed077b62-7048-5752-bf65-f90471f45854`
|
||||
|
||||
**Open tasks:**
|
||||
- ► Task: MCP alias removal (Phase 2) `e2b9015a`
|
||||
- ► Task: Internal dual-key and param alias cleanup `18af98bf`
|
||||
- · Task: Closeout and fleet gate `dcc81665`
|
||||
- ! Execute move/replace slices `3a454438`
|
||||
- ! Retire legacy surfaces `f6cf8c01`
|
||||
- ! Stabilization window and archive prep `9230c99b`
|
||||
- ! Attribute and finish migrating remaining legacy readers `2ac16837`
|
||||
- ! Gate the production inbox reader switch `da1d9a95`
|
||||
|
||||
## Inbox Hygiene
|
||||
|
||||
**Stale unread:** 4 message(s) older than 3 day(s) — triage at session start.
|
||||
**Missing thread_id:** 4 unread message(s) lack supersession chains.
|
||||
- ! activity-core: Optional pattern suggestion: standardized deployment evidence handoff `10d37752`
|
||||
- ! inter-hub: Suggestion: document cross-repo coordination in State Hub suggestions handling `6e8510b1`
|
||||
**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start.
|
||||
**Missing thread_id:** 1 unread message(s) lack supersession chains.
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ PGADMIN_PASSWORD=admin
|
|||
# API
|
||||
API_BASE=http://127.0.0.1:8000
|
||||
|
||||
# Read-only activity-core ops_run projection (STATE-WP-0078).
|
||||
ACTIVITY_CORE_URL=http://127.0.0.1:8010
|
||||
# Inject from OpenBao/ESO in production; never commit the value.
|
||||
ACTIVITY_CORE_WORKER_TOKEN=
|
||||
OPS_RUN_PROJECTION_TTL_SECONDS=15
|
||||
OPS_RUN_SLA_HOURS=1
|
||||
|
||||
# Gitea (for gitea_inventory.py)
|
||||
GITEA_URL=http://92.205.130.254:32166
|
||||
GITEA_TOKEN=
|
||||
|
|
|
|||
|
|
@ -9,11 +9,24 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- main
|
||||
# Must list every path the Dockerfile COPYs, or a change ships to git
|
||||
# without ever reaching the image. This previously covered only api/**,
|
||||
# so edits to mcp_server/ and — more seriously — migrations/ produced no
|
||||
# rebuild (CUST-WP-0067-T08).
|
||||
paths:
|
||||
- ".forgejo/workflows/image.yaml"
|
||||
- "Dockerfile"
|
||||
- "alembic.ini"
|
||||
- "api/**"
|
||||
- "flows/**"
|
||||
- "mcp_server/**"
|
||||
- "migrations/**"
|
||||
- "policies/**"
|
||||
- "prompts/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/**"
|
||||
- "task_flow_engine/**"
|
||||
- "templates/**"
|
||||
- "uv.lock"
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,6 +18,7 @@ __pycache__/
|
|||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
/docs/architecture/.ast_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
|
|
|||
80
AGENTS.md
80
AGENTS.md
|
|
@ -13,13 +13,15 @@
|
|||
|
||||
## State Hub Integration
|
||||
|
||||
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||
there is no MCP server for Codex agents.
|
||||
The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and
|
||||
the `statehub` CLI by default. MCP is opt-in because the current Codex MCP bridge
|
||||
adds severe call latency; the full administrative MCP surface remains available
|
||||
to clients that need it.
|
||||
|
||||
| Context | URL |
|
||||
|---------|-----|
|
||||
| Local workstation | `http://127.0.0.1:8000` |
|
||||
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||
| Remote (railiance01, in-cluster) | `http://10.43.68.154:8000` |
|
||||
| Optional local edge relay | http://127.0.0.1:18080 |
|
||||
|
||||
When an operator has enabled the edge relay, set API_BASE to the relay URL.
|
||||
|
|
@ -27,21 +29,46 @@ Queueable writes return an explicit queued receipt if the central hub is
|
|||
unreachable. Treat that as pending local evidence, then ask the operator to run
|
||||
statehub outbox status/replay after connectivity returns.
|
||||
|
||||
Codex workspace-write sandboxes need network access enabled to reach the host's
|
||||
loopback listener. Bootstrap this once with `make -C ~/state-hub configure-codex`
|
||||
and restart Codex. The canonical REST health endpoint is `/state/health`, not
|
||||
`/health`. If a sandboxed loopback probe fails, retry it with escalated execution
|
||||
before declaring State Hub unavailable; a managed Codex permission profile may
|
||||
still enforce isolated networking. Experimental MCP can be enabled explicitly
|
||||
with `make -C ~/state-hub configure-codex WITH_MCP=1`.
|
||||
|
||||
### Orient at session start
|
||||
|
||||
```bash
|
||||
# Offline brief — works without hub connection
|
||||
cat .custodian-brief.md
|
||||
|
||||
# Active workplans for this domain
|
||||
# Active workplans for this domain (default summary omits flavor: residual)
|
||||
curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||
| python3 -m json.tool
|
||||
# Residuals are not implementation demand unless promoted:
|
||||
# GET /state/summary (open_workplans omits flavor=residual)
|
||||
# GET /workplans/?flavor=residual
|
||||
|
||||
# Check inbox
|
||||
curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \
|
||||
| python3 -m json.tool
|
||||
|
||||
# Optional: DoX quality debt (ready without DoR-Ok, finished without DoD-Ok, …)
|
||||
statehub quality-debt --repo-path .
|
||||
```
|
||||
|
||||
### Definition quality (DoC / DoR / DoD)
|
||||
|
||||
Lifecycle `status` is independent of quality badges. Prefer:
|
||||
|
||||
- **DoC-Ok** on intakes before confident promote (`quality_doc` or note)
|
||||
- **DoR-Ok** on workplans/tasks before heavy implementation (`quality_dor`)
|
||||
- **DoD-Ok** when claiming quality-complete finish (`quality_dod`)
|
||||
|
||||
Recording form and examples: `docs/work-record-quality-gates.md`.
|
||||
Policies: `policies/intake-doc.md`, `work-item-dor.md`, `workstream-dod.md`.
|
||||
|
||||
Mark a message read:
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||
|
|
@ -97,13 +124,19 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||
|
||||
**Close:**
|
||||
1. Update workplan file task statuses to reflect progress
|
||||
2. Log: `POST /progress/` with a summary of what changed
|
||||
3. After workplan file changes, run:
|
||||
2. If finishing a workplan: hand off **residuals** as live work records first
|
||||
(intake with `origin: residual` + `origin_ref: <WP-id>`, or a next workplan /
|
||||
decision / engagement). Do not park leftovers only in prose or `SCOPE.md`.
|
||||
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Residuals.
|
||||
3. Log: `POST /progress/` with a summary of what changed (name handoff ids)
|
||||
4. After workplan file changes, run the fast authoritative projection path:
|
||||
```bash
|
||||
statehub fix-consistency
|
||||
uv run --project ~/repo-manager rmgr sync --path . --push
|
||||
```
|
||||
Coding agents should run this directly; ask the operator only if the CLI or
|
||||
State Hub API is unavailable. This syncs task status from files into the hub DB.
|
||||
This assigns only missing deterministic identifiers, verifies the pushed
|
||||
Forgejo commit, verifies `primary/railliance01`, and reconciles the repository
|
||||
in one request. A queued result is pending evidence; retry after connectivity
|
||||
returns. Run `statehub fix-consistency` separately when a deep audit is needed.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -176,9 +209,12 @@ read/cache/index layer that rebuilds from files.
|
|||
the completion/archive date; the frontmatter `id` does not change.
|
||||
|
||||
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||
this only for low-risk work completed directly; create a normal workplan for
|
||||
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||
`workplans/ADHOC-YYYY-MM-DD.md`, workplan id
|
||||
`STATE-WP-ADHOC-YYYY-MM-DD`, and task ids
|
||||
`STATE-WP-ADHOC-YYYY-MM-DD-T01`, etc. Unqualified historic `ADHOC-*` ids are
|
||||
grandfathered and must not be copied into new records. Use this only for
|
||||
low-risk work completed directly; create a normal workplan for anything needing
|
||||
analysis, design, approval, dependencies, or multiple phases.
|
||||
|
||||
**Frontmatter:**
|
||||
|
||||
|
|
@ -194,7 +230,7 @@ owner: codex
|
|||
topic_slug: ...
|
||||
created: "YYYY-MM-DD"
|
||||
updated: "YYYY-MM-DD"
|
||||
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
state_hub_workstream_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
|
||||
---
|
||||
```
|
||||
|
||||
|
|
@ -211,7 +247,7 @@ derived health labels, not frontmatter statuses.
|
|||
id: STATE-WP-NNNN-T01
|
||||
status: wait | todo | progress | done | cancel
|
||||
priority: high | medium | low
|
||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
state_hub_task_id: "<uuid>" # deterministic UUIDv5; managed by Repo Manager
|
||||
` ` `
|
||||
|
||||
Task description text.
|
||||
|
|
@ -219,7 +255,19 @@ Task description text.
|
|||
|
||||
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||
|
||||
**Residuals when finishing:** actionable leftovers become live work records
|
||||
before `status: finished` — usually an intake (`origin: residual`,
|
||||
`origin_ref: STATE-WP-NNNN`) or a spawned workplan. Residual is a *role*,
|
||||
not a kind. Fleet list lives on State Hub, not in `SCOPE.md`.
|
||||
|
||||
To create a new workplan:
|
||||
1. Write the file following the format above
|
||||
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
|
||||
State Hub API is unavailable.
|
||||
2. Run `uv run --project ~/repo-manager rmgr sync --path . --push`.
|
||||
3. Use `statehub fix-consistency` only for a separate deep consistency audit.
|
||||
|
||||
## Repository / cluster boundary
|
||||
|
||||
Workstation agents commit and push to Forgejo. Cluster infrastructure reads a
|
||||
verified Forge commit into ephemeral storage. Neither side reads or writes the
|
||||
other side's checkout. `local_path` and `host_paths` are host observations, not
|
||||
central storage configuration. See `docs/adr/ADR-002-forge-repository-boundary.md`.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ COPY policies/ ./policies/
|
|||
COPY prompts/ ./prompts/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY task_flow_engine/ ./task_flow_engine/
|
||||
# ACTIVITY-WP-0021 / ROS hourly: domain-digest.md must exist at
|
||||
# templates/recently-on-scope/ for /recently-on-scope/hourly rendering.
|
||||
COPY templates/ ./templates/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
|
|
|||
21
INTENT.md
21
INTENT.md
|
|
@ -9,6 +9,26 @@ updated: "2026-05-17"
|
|||
> This file explains why State Hub exists, where its authority begins and ends,
|
||||
> and why it is now separate from the-custodian.
|
||||
|
||||
## Retirement status
|
||||
|
||||
State Hub is a legacy service in active retirement planning. It remains
|
||||
operational during the migration, but it is not the target home for new
|
||||
cross-domain capabilities.
|
||||
|
||||
The replacement program is coordinated in
|
||||
`/home/worsch/prj-state-hub-retirement`, whose `GOAL.md` defines the migration
|
||||
and retirement gates. Repository representation, file-backed record indexing,
|
||||
consistency reconciliation, and governed repository changes are moving toward
|
||||
`/home/worsch/repo-manager`. Cross-domain identity, communication, interaction,
|
||||
state projections, telemetry, and domain-extension composition are moving
|
||||
toward the consolidated `hub-core` framework.
|
||||
|
||||
Until the retirement gates are met, State Hub remains authoritative only where
|
||||
the current contracts and repository-backed workflows require it. Changes made
|
||||
during this period should preserve compatibility, enable extraction, reduce
|
||||
scope, or address operational risk; they should not establish new permanent
|
||||
architectural ownership here.
|
||||
|
||||
---
|
||||
|
||||
## Why it exists
|
||||
|
|
@ -126,4 +146,3 @@ visible rather than fail opaquely when one component is unavailable.
|
|||
|
||||
**Narrow authority.** State Hub coordinates and exposes state; it does not make
|
||||
irreversible human decisions or become the owner of every adjacent system.
|
||||
|
||||
|
|
|
|||
161
LICENSE
161
LICENSE
|
|
@ -1,16 +1,151 @@
|
|||
MIT No Attribution
|
||||
# Target Revenue Source License
|
||||
|
||||
Copyright <YEAR> <COPYRIGHT HOLDER>
|
||||
**Version 1.0, Candidate 1 (V1C1)**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify,
|
||||
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so.
|
||||
---
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
> **PRELIMINARY CANDIDATE — SUBJECT TO CHANGE — NOT FINAL**
|
||||
>
|
||||
> This repository is governed by the Target Revenue Source License
|
||||
> (TRSL), Version 1, Candidate 1. This is the framework's first working
|
||||
> candidate, adopted as the org's preliminary operating license across
|
||||
> all repos (maintainer decision, 2026-07-29) during the build/alpha
|
||||
> stage — see `SCOPE.md` §1 and Appendix A of the canonical text (link
|
||||
> below) for the alpha/beta risk-acceptance decision this reflects and
|
||||
> what it does and does not mean. Full specialist legal review is
|
||||
> explicitly deferred until the framework moves out of beta.
|
||||
>
|
||||
> **Canonical source, full candidate-status banner, and Appendix A
|
||||
> (non-normative candidate notes tracking every open item):** the
|
||||
> `coulomb` org's `target-revenue` repository,
|
||||
> `specs/TargetRevenueSourceLicense-V1C1.md` — this file is the operative
|
||||
> legal text (Preamble through Section 11) only; the canonical document
|
||||
> is authoritative if this copy and that document ever diverge.
|
||||
|
||||
---
|
||||
|
||||
## Preamble
|
||||
|
||||
This Target Revenue Source License ("**License**") governs the Software identified in the applicable Phase Manifest. It implements the Target Revenue Framework: a defined development Phase accumulates Development Credit and Remission Credit against an immutable Initial Target until the Milestone Release automatically and irrevocably converts to a declared permissive Future License.
|
||||
|
||||
Commercial beneficiaries fund the creation and early availability of a software improvement; once the declared target is satisfied, the governed release becomes permissively open source.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
Capitalized terms used in this License have the meanings given below. Where a term is also defined in the Phase Manifest or Target Ledger for a specific Phase, the Phase Manifest and Target Ledger govern the *values* (amounts, dates, identifiers) and this License governs the *legal effect* of those values — the two must not be read as conflicting definitions of the same concept.
|
||||
|
||||
**"Commercial Entitlement"** means a right, purchased or otherwise granted under a Commercial Use Agreement, to make Commercial Use of the Software during a Phase.
|
||||
|
||||
**"Commercial Use"** means billing, invoicing, or otherwise charging any customer a fee, subscription, license fee, or other consideration for or in connection with use of the Software, at any time before the Conversion Event for the applicable Phase, regardless of whether the person or organization billed would otherwise qualify for Noncommercial Use. Commercial Use occurs by virtue of such billing alone, whether or not the resulting payment is registered with the Trust Service; in particular, billing a customer for pre-conversion use of the Software without recording the corresponding payment in the applicable Phase's Target Ledger is Commercial Use without a valid Commercial Entitlement — a violation of Section 3, addressed under Section 7 and, where applicable, the Enforcement Network described in the canonical repository's `specs/EnforcementNetworkConcept.md`.
|
||||
|
||||
**"Commercial Use Agreement"** means the separate agreement, referenced by the applicable Phase Manifest, under which a Commercial Entitlement is purchased or granted. This License does not itself set pricing, metering, or payment terms — those are governed by the Commercial Use Agreement.
|
||||
|
||||
**"Conversion Event"** means the moment the Outstanding Target for a Phase reaches zero, as computed from the Phase Manifest and Target Ledger per the Target Ledger Specification. The Conversion Event occurs automatically and is not conditioned on any declaration, attestation, or other act by the Licensor or any Trust Service.
|
||||
|
||||
**"Development Credit"** means the portion of a collected and settled payment explicitly allocated toward satisfying the Initial Target of a specific Phase, as recorded in that Phase's Target Ledger.
|
||||
|
||||
**"Future License"** means the permissive license identified in the applicable Phase Manifest, being either the MIT License or the Apache License, Version 2.0, which applies to the Milestone Release upon the Conversion Event.
|
||||
|
||||
**"Initial Target"** means the immutable monetary target declared for a Phase in its Phase Manifest.
|
||||
|
||||
**"Licensor"** means **Binky Hedgehog GmbH**, the party that publishes the Phase Manifest and holds the rights necessary to grant this License and the Future License for the Milestone Release.
|
||||
|
||||
**"Milestone Release"** means the precisely identified software release designated in the applicable Phase Manifest, identified by an immutable source revision, release artifact, or cryptographic digest.
|
||||
|
||||
**"Noncommercial Use"** means use of the Software for personal purposes, private study, hobby or amateur projects; use by any charitable organization, educational institution, public research organization, or government institution acting in a non-revenue-generating capacity; or other use of a materially similar character.
|
||||
|
||||
**"Outstanding Target"** means, at any time, `max(0, Initial Target − cumulative Development Credit − cumulative Remission Credit)` for a Phase, as computed from that Phase's Target Ledger.
|
||||
|
||||
**"Phase"** means a bounded development undertaking governed by one Initial Target, one Milestone Release, one degeneration policy, and one Future License declaration, as declared in a Phase Manifest.
|
||||
|
||||
**"Phase Manifest"** means the published, immutable declaration identifying a Phase, its Milestone Release, Initial Target, Future License, degeneration policy, and Target Ledger location, as specified in the Phase Manifest Specification.
|
||||
|
||||
**"Remission Credit"** means a transparent, non-revenue reduction of a Phase's Outstanding Target, generated under that Phase's published degeneration policy and recorded in the Target Ledger.
|
||||
|
||||
**"Settled Payment"** means a payment that has cleared through its payment processor and is no longer subject to reversal in the ordinary course (chargeback, dispute, or equivalent), as further specified by the applicable Commercial Use Agreement or monetization extension.
|
||||
|
||||
**"Software"** means the source code, object code, and associated documentation of the Milestone Release identified in the applicable Phase Manifest.
|
||||
|
||||
**"Target Ledger"** means the append-only record of Development Credit, Remission Credit, and correction entries for a Phase, as specified in the Target Ledger Specification.
|
||||
|
||||
**"You"** or **"Licensee"** means the individual or entity exercising rights under this License.
|
||||
|
||||
## 2. Grant of Rights for Noncommercial Use
|
||||
|
||||
Subject to the terms of this License, the Licensor grants You a worldwide, royalty-free, non-exclusive license, during the applicable Phase, to:
|
||||
|
||||
(a) use, reproduce, and study the Software for any Noncommercial Use;
|
||||
|
||||
(b) modify the Software and create derivative works of it for any Noncommercial Use; and
|
||||
|
||||
(c) redistribute the Software and Your modifications, in source or object form, for any Noncommercial Use, provided that You include this License, unmodified, with any such redistribution, and that You do not remove or alter any copyright, patent, trademark, or attribution notices contained in the Software.
|
||||
|
||||
This grant does not extend to Commercial Use. Commercial Use requires a Commercial Entitlement under Section 3.
|
||||
|
||||
## 3. Commercial Use
|
||||
|
||||
You may not make Commercial Use of the Software during the applicable Phase unless You hold a valid, current Commercial Entitlement under a Commercial Use Agreement with the Licensor covering the applicable Phase. A Commercial Entitlement granted under one Phase's Commercial Use Agreement does not extend to a later Phase's Milestone Release unless the Commercial Use Agreement expressly says so.
|
||||
|
||||
This Section 3 states the existence and boundary of the commercial-use restriction. It does not itself set pricing, invoicing, metering, audit rights, or payment terms — those are governed exclusively by the applicable Commercial Use Agreement.
|
||||
|
||||
## 4. Patent License
|
||||
|
||||
Subject to the terms of this License, each contributor to the Software grants You, during the applicable Phase and solely to the extent of rights granted under Sections 2 and 3, a perpetual (subject to the termination below), worldwide, non-exclusive, no-charge, royalty-free patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Software, limited to those patent claims licensable by that contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software.
|
||||
|
||||
If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Software or a contribution incorporated within it constitutes direct or contributory patent infringement, then any patent licenses granted to You under this Section 4 for the Software shall terminate as of the date such litigation is filed.
|
||||
|
||||
## 5. Automatic Conversion to the Future License
|
||||
|
||||
**5.1 Automatic effect.** Upon the Conversion Event for a Phase, the rights and restrictions in Sections 3 (Commercial Use) of this License, as they apply to that Phase's Milestone Release, terminate automatically. In their place, the Milestone Release is licensed under the Future License identified in that Phase's Phase Manifest, effective as of the Conversion Event, without any further act, declaration, or attestation required by the Licensor, any Trust Service, or any other party.
|
||||
|
||||
**5.2 Irrevocability.** Once a valid Conversion Event has occurred for a Phase, no subsequent refund, chargeback, accounting correction, dispute, or termination of this License for an unrelated breach shall revoke, suspend, or otherwise impair the Future License grant for that Phase's Milestone Release. Any shortfall or dispute arising after a Conversion Event is a commercial or accounting matter between the relevant parties and does not reinstate a commercial-use restriction over already-converted Software.
|
||||
|
||||
**5.3 Prior freedom preserved.** A later Phase covering subsequent improvements to the Software does not restrict, withdraw, or otherwise affect the rights granted under the Future License for an earlier Phase's already-converted Milestone Release.
|
||||
|
||||
**5.4 Evidence, not cause.** A Trust Service may publish a Conversion Attestation documenting a Conversion Event. Such an attestation is evidence that the Conversion Event occurred; it is not a condition of, and its absence or delay does not postpone, the automatic effect described in Section 5.1. Any person may independently verify whether a Conversion Event has occurred directly from the Phase Manifest and Target Ledger.
|
||||
|
||||
## 6. Successive Phases
|
||||
|
||||
The Licensor may declare a new Phase covering subsequent improvements to the Software following a Milestone Release's Conversion Event. Each Phase is independently governed by its own Phase Manifest, Initial Target, degeneration policy, and Target Ledger. Nothing in a later Phase's Phase Manifest may be construed to reduce or withdraw rights already granted under Section 5 for an earlier Phase's Milestone Release.
|
||||
|
||||
## 7. Term and Termination
|
||||
|
||||
**7.1 Term.** This License applies to the Software for the duration of the applicable Phase, and, for the Milestone Release, indefinitely following that Phase's Conversion Event under the Future License.
|
||||
|
||||
**7.2 Termination for breach.** If You breach Section 3 (Commercial Use) or Section 2(c) (redistribution notice requirement), the Licensor may terminate this License as to You. Before such termination becomes effective, the Licensor shall provide You written notice of the breach; if You cure the breach within thirty (30) days of that notice, this License continues in effect. A second breach of the same provision within twelve (12) months may be terminated immediately without a further cure opportunity.
|
||||
|
||||
**7.3 Effect of termination.** Termination under this Section 7 affects only Your rights under Sections 2 and 3 for the Phase in which the breach occurred. It does not affect any rights already vested under Section 5 (Automatic Conversion) for a Milestone Release whose Conversion Event has already occurred, per Section 5.2.
|
||||
|
||||
**7.4 Public record of breach and resolution.** The Licensor shall cause the Trust Service to publish, as part of the public record for the affected Phase, notice of: (a) any breach notice issued under Section 7.2, stating the general nature of the breach and the date of notice; (b) whether the breach was cured within the applicable cure period, and the date of cure; and (c) any termination determination made under this Section 7, including its effective date and scope. This public record exists to give the ecosystem a transparent, verifiable conformity signal for the Phase, distinct from and in addition to the Development Credit and Remission Credit facts already published under Section 5.4 and the Target Ledger Specification.
|
||||
|
||||
A breach that You dispute, and that has not been finally determined, shall be recorded as **alleged**; it shall be recorded as **determined** only once the cure period has run without cure, or the dispute has been resolved against You under the applicable Commercial Use Agreement's dispute process, if any. The Trust Service shall update the record promptly upon resolution in either direction. Recording an alleged or determined breach under this Section 7.4 is a ministerial act of publishing the Licensor's determination (or a dispute process's outcome); it does not give the Trust Service discretionary authority to decide whether a breach occurred, consistent with Section 5.4's evidence-not-cause principle.
|
||||
|
||||
Whether, and under what conditions, the public record identifies a Commercial Entitlement holder by name is governed exclusively by the applicable Commercial Use Agreement, which the Licensor and that Commercial Entitlement holder negotiate and agree to directly. This License does not itself set a naming default. Where no Commercial Use Agreement addresses the question, or where the affected party has no Commercial Use Agreement at all (for example, a Section 2(c) breach by a Noncommercial Use licensee), the public record states the Phase and breach category only, without naming the party.
|
||||
|
||||
## 8. Disclaimer of Warranty
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR THAT ANY PHASE WILL REACH ITS CONVERSION EVENT.
|
||||
|
||||
## 9. Limitation of Liability
|
||||
|
||||
IN NO EVENT SHALL THE LICENSOR OR ANY CONTRIBUTOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, EXCEPT TO THE EXTENT SUCH LIMITATION IS PROHIBITED BY APPLICABLE LAW.
|
||||
|
||||
## 10. Trademarks
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary attribution.
|
||||
|
||||
## 11. General Provisions
|
||||
|
||||
**11.1 Governing law and venue.** Adopted for alpha/beta 2026-07-29: disputes arising under this License shall be resolved by binding arbitration, seated at a neutral, arbitration-mature venue (Singapore or London are the two candidates concretely supported by current research), rather than by litigation in a national court. The specific arbitral institution and substantive governing law remain a per-deployment blank pending final selection; they are not fixed by this candidate. See the canonical repository's `history/260729-TRSL-Jurisdiction-Synthesis.md` §2.
|
||||
|
||||
**11.2 Severability.** If any provision of this License is held unenforceable, the remaining provisions remain in full force, and the unenforceable provision shall be reformed to the minimum extent necessary to make it enforceable.
|
||||
|
||||
**11.3 No waiver.** Failure to enforce any provision of this License is not a waiver of future enforcement of that or any other provision.
|
||||
|
||||
**11.4 Entire agreement (as to licensing).** This License, together with the applicable Phase Manifest and, where applicable, the Commercial Use Agreement, constitutes the entire agreement between You and the Licensor regarding the Software's licensing terms. Operations, service, and consulting arrangements are governed by separate agreements, if any, and are not part of this License.
|
||||
|
||||
**11.5 Definitions control.** Marketing materials, documentation, or other non-normative communications about the Software must not describe pre-Conversion-Event Software as "Open Source," "free software," or "open core." Pre-conversion Noncommercial Use is **source-available**; pre-conversion Commercial Use requires a **Commercial Entitlement**; only post-conversion Software may be described as Open Source, under the Future License.
|
||||
|
||||
---
|
||||
|
||||
**No Phase is currently declared for this repository under this License.** Until a Phase Manifest is published and registered with the Trust Service for a Milestone Release in this repository, Sections 2–7 above have no operative subject matter here — this License establishes the governing framework in advance of that declaration, consistent with the org-wide rollout decision recorded in `target-revenue`'s `workplans/TREV-WP-0008-governance-and-pilot-rollout.md`.
|
||||
|
|
|
|||
113
Makefile
113
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.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 dev-hub edge-relay mcp-profile 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
|
||||
.PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile 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 railiance-state-hub-headroom
|
||||
|
||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||
PYTHON ?= python3
|
||||
|
|
@ -9,19 +9,28 @@ RAILIANCE_STATE_HUB_RELEASE ?= state-hub
|
|||
RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub
|
||||
RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub
|
||||
RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml
|
||||
RAILIANCE_STATE_HUB_IMAGE_TAG ?= f2e042a
|
||||
RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-0747b4d
|
||||
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)
|
||||
RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi)
|
||||
STATE_HUB_API_BASE ?= http://127.0.0.1:8000
|
||||
SYNC_PATH ?= .
|
||||
SYNC_PUSH ?= 1
|
||||
|
||||
start:
|
||||
@echo "# run in different terminals"
|
||||
@echo "make db # docker compose up postgres"
|
||||
@echo "make api # start backend api"
|
||||
@echo "make mcp-http # start state-hub mcp service"
|
||||
@echo "make dashboard # Observable dev server on :3000"
|
||||
@echo "make bridges # Set up ssh bridges for cross machines access"
|
||||
@echo "# Normal production UI access (run in order)"
|
||||
@echo "make bridges # connect local :8000 to the railiance01 primary"
|
||||
@echo "make dashboard # verify the primary, then serve the UI on :3000"
|
||||
@echo "make sync # push file-backed records and reconcile the exact forge commit"
|
||||
@echo ""
|
||||
@echo "# Optional local MCP adapter"
|
||||
@echo "make mcp-http # local SSE adapter on :8001; not needed by the UI"
|
||||
@echo ""
|
||||
@echo "# Local fallback/development only"
|
||||
@echo "make api # local Postgres + API; conflicts with the production :8000 tunnel"
|
||||
@echo "make dashboard-local # dashboard against the deliberate local API"
|
||||
|
||||
install:
|
||||
$(UV) sync
|
||||
|
|
@ -63,15 +72,80 @@ mcp-http:
|
|||
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
|
||||
MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py
|
||||
|
||||
dashboard:
|
||||
## Require the production identity before serving the normal dashboard. This
|
||||
## prevents an accidental local `make api` from presenting an empty fallback DB
|
||||
## as the live State Hub.
|
||||
check-primary:
|
||||
@health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \
|
||||
echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \
|
||||
echo "Run 'make bridges' first." >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
|
||||
if [ "$$identity" != "primary/railiance01" ]; then \
|
||||
echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \
|
||||
echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
## Refuse to start the production tunnel when a non-primary API already owns
|
||||
## port 8000. With no listener, bridge is free to establish the tunnel.
|
||||
primary-port-clear:
|
||||
@if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \
|
||||
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
|
||||
if [ "$$identity" != "primary/railiance01" ]; then \
|
||||
echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \
|
||||
echo "Stop the local 'make api' process before starting the production tunnel." >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
dashboard: check-primary
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
$(MAKE) dashboard-install
|
||||
cd dashboard && npm run dev
|
||||
|
||||
check:
|
||||
## Deliberate local-development dashboard; bypasses the production identity gate.
|
||||
dashboard-local:
|
||||
@echo "WARNING: serving the dashboard against the local/fallback API on :8000."
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
$(MAKE) dashboard-install
|
||||
cd dashboard && npm run dev
|
||||
|
||||
## Fast work-record path. Repo Manager assigns only missing deterministic IDs,
|
||||
## verifies the primary/railliance01 bridge, and asks central to derive the
|
||||
## exact pushed Forgejo commit in one transactional request.
|
||||
sync:
|
||||
$(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,)
|
||||
|
||||
check: check-primary
|
||||
@echo "State Hub primary/railiance01 is healthy."
|
||||
|
||||
check-local:
|
||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||
|
||||
railiance-state-hub-render:
|
||||
# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values
|
||||
# because a container has no checkout to read it from. Regenerate after any
|
||||
# canon vocabulary change; the -check target fails on drift so a release cannot
|
||||
# validate classification against a stale vocabulary.
|
||||
sync-classification-allowed:
|
||||
python3 scripts/sync_classification_allowed.py
|
||||
|
||||
check-classification-allowed:
|
||||
python3 scripts/sync_classification_allowed.py --check
|
||||
|
||||
# STATE-WP-0091: read-only surge-headroom check. Does not start Helm.
|
||||
# Live: make railiance-state-hub-headroom
|
||||
# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json
|
||||
HEADROOM_FIXTURE ?=
|
||||
railiance-state-hub-headroom:
|
||||
@if [ -n "$(HEADROOM_FIXTURE)" ]; then \
|
||||
$(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \
|
||||
else \
|
||||
$(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \
|
||||
fi
|
||||
|
||||
railiance-state-hub-render: check-classification-allowed
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
|
|
@ -126,7 +200,7 @@ benchmark-summary-cache:
|
|||
|
||||
## ops-bridge managed tunnels
|
||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||
tunnels-up:
|
||||
tunnels-up: primary-port-clear
|
||||
bridge up
|
||||
|
||||
tunnels-status:
|
||||
|
|
@ -139,7 +213,7 @@ tunnels-check:
|
|||
|
||||
## Ensure all ops-bridge tunnels are up and healthy.
|
||||
## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down.
|
||||
bridges:
|
||||
bridges: primary-port-clear
|
||||
@echo "==> Bringing up all tunnels..."
|
||||
bridge up
|
||||
@echo ""
|
||||
|
|
@ -149,9 +223,10 @@ bridges:
|
|||
@echo "==> Checking tunnel health..."
|
||||
bridge check
|
||||
|
||||
## Start (or restart) the full backend — db + migrate + uvicorn.
|
||||
## Stops uvicorn on :8000 if already running, then starts fresh.
|
||||
## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn.
|
||||
## This replaces anything on :8000, including the production State Hub tunnel.
|
||||
api: db
|
||||
@echo "WARNING: starting the local fallback API; this is not the railiance01 primary."
|
||||
@echo "Waiting for postgres..."; \
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do \
|
||||
nc -z 127.0.0.1 5432 2>/dev/null && break; \
|
||||
|
|
@ -180,6 +255,14 @@ register-mcp:
|
|||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Configure Codex workspace-write networking for local State Hub access.
|
||||
configure-codex:
|
||||
scripts/configure-codex.sh \
|
||||
$(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(WITH_MCP),--with-mcp,) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing"
|
||||
bootstrap-env:
|
||||
scripts/bootstrap-env.sh $(ARGS)
|
||||
|
|
|
|||
78
README.md
78
README.md
|
|
@ -64,11 +64,15 @@ then run consistency sync.
|
|||
|
||||
All services bind to `127.0.0.1` only — nothing exposed to the network.
|
||||
|
||||
**Production:** the primary State Hub API runs on coulombcore-k3s. Workstation
|
||||
port `8000` reaches it through the ops-bridge `state-hub-primary` tunnel. See
|
||||
**Production:** the primary State Hub API runs in the railiance01-hosted k3s
|
||||
cluster. Workstation port `8000` reaches it through the ops-bridge
|
||||
`state-hub-primary` tunnel. See
|
||||
[`docs/cluster-operating-model.md`](docs/cluster-operating-model.md) for access,
|
||||
rollback, backups, and pragmatic limitations.
|
||||
|
||||
Repository rename operations use the phased, UUID-preserving workflow in
|
||||
[`docs/repository-rename-operations.md`](docs/repository-rename-operations.md).
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
|
@ -79,7 +83,11 @@ rollback, backups, and pragmatic limitations.
|
|||
- Python 3.12+ with `uv` (`pip install uv`)
|
||||
- Node.js 18+ (dashboard only)
|
||||
|
||||
### First-time
|
||||
### First-time local development setup
|
||||
|
||||
The local database and API are retained for development and disaster fallback.
|
||||
They are not the normal production access path and conflict with the production
|
||||
tunnel on port `8000`.
|
||||
|
||||
```bash
|
||||
cd /home/worsch/state-hub
|
||||
|
|
@ -89,27 +97,51 @@ make install # uv sync
|
|||
make db # docker compose up postgres
|
||||
make migrate # alembic upgrade head
|
||||
make seed # insert 6 canonical topics
|
||||
make api # db + migrate + uvicorn :8000 (restarts if running)
|
||||
make api # LOCAL fallback: db + migrate + uvicorn :8000
|
||||
```
|
||||
|
||||
### Dashboard
|
||||
For a dashboard against that deliberate local backend, use
|
||||
`make dashboard-local`.
|
||||
|
||||
### Production dashboard (normal operation)
|
||||
|
||||
The dashboard remains a workstation process, but its API is the primary State
|
||||
Hub on railiance01. Start the tunnel first, then start the dashboard:
|
||||
|
||||
```bash
|
||||
make dashboard # installs dashboard deps if needed, then Observable dev server on :3000
|
||||
make dashboard-check # installs deps if needed, then runs Observable build
|
||||
make bridges # connect workstation :8000 to primary/railiance01
|
||||
make dashboard # verify the primary identity, then serve http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
### Start Everything
|
||||
`make dashboard` refuses to start if port `8000` is unreachable or identifies
|
||||
itself as anything other than `primary/railiance01`. This prevents the empty
|
||||
local fallback database from being mistaken for production.
|
||||
|
||||
To start all the infrastructure on separate consoles do:
|
||||
The MCP adapter is optional and is not required by the dashboard:
|
||||
|
||||
```bash
|
||||
make db # docker compose up postgres
|
||||
make mcp-http # start state-hub mcp service
|
||||
make dashboard # Observable dev server on :3000
|
||||
make bridges # Set up ssh bridges for cross machines access
|
||||
make mcp-http # optional local SSE adapter on :8001
|
||||
```
|
||||
|
||||
### Fast work-record synchronization
|
||||
|
||||
Workplan files remain authoritative. Repo Manager assigns missing UUIDv5
|
||||
identifiers locally, pushes the file commit, and asks the primary State Hub to
|
||||
derive that exact commit from Forgejo in one transactional request:
|
||||
|
||||
```bash
|
||||
make bridges
|
||||
make sync
|
||||
```
|
||||
|
||||
The sync refuses an uncommitted or behind branch and will not write to a local
|
||||
empty database: the API must identify itself as `primary/railliance01`. If the
|
||||
primary is unavailable, Repo Manager writes an explicit pending receipt under
|
||||
the checkout's local `.git/repo-manager/` state; rerunning `make sync` safely
|
||||
replays the current pushed state.
|
||||
|
||||
Use `statehub fix-consistency` only for the broader consistency/quality audit.
|
||||
It is no longer the normal registration and task-update path.
|
||||
|
||||
### CLI
|
||||
|
||||
|
|
@ -127,17 +159,22 @@ custodian register-project # register cwd as a Custodian project
|
|||
|--------|-------------|
|
||||
| `make install` | `uv sync` — install Python deps + entry points |
|
||||
| `make install-cli` | Symlink `custodian` to `~/.local/bin` |
|
||||
| `make db` | Start postgres container |
|
||||
| `make start` | Print the production UI and local fallback startup paths |
|
||||
| `make bridges` | Connect managed tunnels, including workstation `:8000` to the railiance01 primary |
|
||||
| `make db` | Start the local fallback/development 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 (legacy bootstrap) |
|
||||
| `make register-from-classification REPO=slug` | Upsert repo from `.repo-classification.yaml` |
|
||||
| `make register-from-classification-all` | Bulk reclassify all repos with classification files |
|
||||
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
|
||||
| `make api` | Start the local fallback API; replaces the production tunnel on `:8000` |
|
||||
| `make dashboard-install` | Install dashboard npm deps from `dashboard/package-lock.json` |
|
||||
| `make dashboard-check` | Build the Observable dashboard as a smoke/regression check |
|
||||
| `make dashboard` | Install deps if needed, then start Observable dev server (restarts if running) |
|
||||
| `make check` | `curl /state/health` |
|
||||
| `make dashboard` | Verify `primary/railiance01`, then start the Observable dashboard |
|
||||
| `make dashboard-local` | Start the dashboard against an intentional local/fallback API |
|
||||
| `make sync` | Push missing deterministic IDs and reconcile this repo from the exact Forgejo commit |
|
||||
| `make check` | Require a healthy `primary/railiance01` on workstation `:8000` |
|
||||
| `make check-local` | Print `/state/health` without enforcing production identity |
|
||||
| `make test` | Python test suite plus `make dashboard-check` |
|
||||
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
|
||||
| `make clean` | `docker compose down -v` (destroys DB volume) |
|
||||
|
|
@ -219,7 +256,7 @@ use `/state/health`, not `/state/summary`.
|
|||
|--------|-----------|
|
||||
| `/topics` | CRUD (soft-delete: `archived`) |
|
||||
| `/workplans` | Preferred CRUD surface for repo-backed workplans (soft-delete: `archived`) |
|
||||
| `/workstreams` | Legacy compatibility CRUD surface; usage is recorded by legacy-meter |
|
||||
| `/workstreams` | Retired REST routes (410); use `/workplans`. Rejected calls remain metered |
|
||||
| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
|
||||
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
|
||||
| `/progress` | `GET` list + `POST` append — no DELETE |
|
||||
|
|
@ -227,7 +264,8 @@ use `/state/health`, not `/state/summary`.
|
|||
| `/state/summary` | Full snapshot |
|
||||
| `/state/health` | DB connectivity check |
|
||||
|
||||
See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
|
||||
See `docs/work-record-flavor.md` for flavor buckets and residual
|
||||
default-exclusion (STATE-WP-0092). See `docs/workplan-terminology-transition.md` for the workstream-to-workplan
|
||||
compatibility policy and retirement criteria.
|
||||
|
||||
---
|
||||
|
|
@ -285,7 +323,7 @@ Prints API health, totals, and any blocking decisions.
|
|||
|
||||
### What `register-project` does
|
||||
|
||||
1. Verifies the API is reachable (fails fast with `make api` hint)
|
||||
1. Verifies the API is reachable
|
||||
2. Looks up the topic ID for the domain via `/topics/?status=active`
|
||||
3. Checks that `state-hub` is in `~/.claude.json`
|
||||
4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template`
|
||||
|
|
|
|||
14
SCOPE.md
14
SCOPE.md
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
## One-Liner
|
||||
|
||||
State Hub is the local-first coordination service for repo-anchored workplans,
|
||||
State Hub is the transitional coordination service for repo-anchored workplans,
|
||||
tasks, decisions, progress events, repo classification and metadata, MCP
|
||||
tooling, and dashboard telemetry.
|
||||
tooling, and dashboard telemetry. Its primary runtime is on railiance01; local
|
||||
repository files remain authoritative for repository-owned work records.
|
||||
|
||||
## In Scope
|
||||
|
||||
|
|
@ -23,13 +24,16 @@ tooling, and dashboard telemetry.
|
|||
|
||||
| Repo | Role |
|
||||
| --- | --- |
|
||||
| `hub-core` | Shared library — State Hub mounts router factories and MCP from `hub_core` |
|
||||
| `state-hub` | This repo — dev coordination host, workplan file authority |
|
||||
| `core-hub` | Production framework — `/api/v2` at `hub.coulomb.social`; does not replace State Hub dev MCP |
|
||||
| `hub-core` | Shared library and selected target runtime for capabilities retained after State Hub retirement |
|
||||
| `state-hub` | This repo — transitional coordination runtime and read projection; repository files retain work-record authority |
|
||||
| `core-hub` | Production `/api/v2` compatibility runtime on railiance01 during consolidation into `hub-core` |
|
||||
|
||||
Canon: `/home/worsch/the-custodian/docs/hub-ecosystem-architecture.md`
|
||||
|
||||
State Hub does **not** implement Inter-Hub `/api/v2` routes; those belong to core-hub.
|
||||
New permanent cross-domain capability should land in its disposition owner, not
|
||||
expand State Hub. The retirement program is governed by
|
||||
`prj-state-hub-retirement`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
|
|
|
|||
365
WORK-RECORDS.md
Normal file
365
WORK-RECORDS.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# Work Records — state-hub
|
||||
|
||||
> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record
|
||||
> stage 3). Do not edit by hand — edit the source file/block listed for
|
||||
> each record and re-run fix-consistency to refresh this index. Archived
|
||||
> workplans are omitted; closed decisions/intakes/engagements stay listed
|
||||
> so recently-resolved work is still visible. [auto]
|
||||
|
||||
| Kind | ID | Status | Lane | Source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| workplan | STATE-WP-ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md |
|
||||
| workplan | STATE-WP-ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md |
|
||||
| workplan | STATE-WP-ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md |
|
||||
| workplan | STATE-WP-ADHOC-2026-08-23 | finished | — | workplans/ADHOC-2026-08-23.md |
|
||||
| workplan | STATE-WP-ADHOC-2026-08-30 | finished | — | workplans/ADHOC-2026-08-30.md |
|
||||
| workplan | STATE-WP-ADHOC-2026-09-04 | finished | — | workplans/ADHOC-2026-09-04.md |
|
||||
| workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| workplan | CUST-WP-0042 | finished | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| workplan | STATE-WP-0043 | finished | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| workplan | STATE-WP-0044 | finished | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| workplan | STATE-WP-0045 | finished | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| workplan | STATE-WP-0046 | finished | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| workplan | STATE-WP-0047 | finished | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| workplan | STATE-WP-0048 | finished | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| workplan | STATE-WP-0049 | finished | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| workplan | STATE-WP-0050 | finished | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
|
||||
| workplan | STATE-WP-0051 | finished | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| workplan | STATE-WP-0052 | finished | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| workplan | STATE-WP-0053 | finished | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| workplan | STATE-WP-0054 | finished | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| workplan | STATE-WP-0055 | finished | — | workplans/STATE-WP-0055-statehub-register-cli.md |
|
||||
| workplan | STATE-WP-0056 | finished | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| workplan | STATE-WP-0057 | finished | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| workplan | STATE-WP-0058 | finished | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
|
||||
| workplan | STATE-WP-0059 | finished | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
|
||||
| workplan | STATE-WP-0060 | finished | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
|
||||
| workplan | STATE-WP-0062 | finished | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
|
||||
| workplan | STATE-WP-0063 | finished | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
|
||||
| workplan | STATE-WP-0064 | finished | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
|
||||
| workplan | STATE-WP-0065 | finished | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
|
||||
| workplan | STATE-WP-0066 | finished | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| workplan | STATE-WP-0067 | finished | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| workplan | STATE-WP-0068 | finished | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| workplan | STATE-WP-0071 | finished | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| workplan | STATE-WP-0072 | finished | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
|
||||
| workplan | STATE-WP-0073 | finished | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
|
||||
| workplan | STATE-WP-0074 | finished | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
|
||||
| workplan | STATE-WP-0075 | finished | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
|
||||
| workplan | STATE-WP-0076 | finished | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| workplan | STATE-WP-0077 | finished | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
|
||||
| workplan | STATE-WP-0078 | finished | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
|
||||
| workplan | STATE-WP-0079 | blocked | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| workplan | STATE-WP-0080 | finished | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| workplan | STATE-WP-0081 | finished | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| workplan | STATE-WP-0082 | finished | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| workplan | STATE-WP-0083 | finished | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| workplan | STATE-WP-0084 | finished | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| workplan | STATE-WP-0085 | finished | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| workplan | STATE-WP-0086 | finished | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
|
||||
| workplan | STATE-WP-0087 | finished | — | workplans/STATE-WP-0087-bounded-repository-collection-projection.md |
|
||||
| workplan | STATE-WP-0088 | finished | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md |
|
||||
| workplan | STATE-WP-0089 | finished | — | workplans/STATE-WP-0089-rename-redirect-recovery.md |
|
||||
| workplan | STATE-WP-0090 | finished | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
|
||||
| workplan | STATE-WP-0091 | finished | — | workplans/STATE-WP-0091-release-headroom.md |
|
||||
| workplan | STATE-WP-0092 | finished | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
|
||||
| task | STATE-WP-ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md |
|
||||
| task | STATE-WP-ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md |
|
||||
| task | STATE-WP-ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-08-T01 | done | — | workplans/ADHOC-2026-08-08.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-08-T02 | done | — | workplans/ADHOC-2026-08-08.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-30-T01 | done | — | workplans/ADHOC-2026-08-30.md |
|
||||
| task | STATE-WP-ADHOC-2026-08-30-T02 | done | — | workplans/ADHOC-2026-08-30.md |
|
||||
| task | STATE-WP-ADHOC-2026-09-04-T01 | done | — | workplans/ADHOC-2026-09-04.md |
|
||||
| task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T04 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T05 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T06 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T07 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T08 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0003-T09 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
|
||||
| task | CUST-WP-0012-T01 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| task | CUST-WP-0012-T02 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| task | CUST-WP-0012-T03 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| task | CUST-WP-0012-T04 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| task | CUST-WP-0012-T05 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| task | CUST-WP-0012-T06 | done | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
|
||||
| task | CUST-WP-0038-T01 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T02 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T03 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T04 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T05 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T06 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T07 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0038-T08 | todo | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
|
||||
| task | CUST-WP-0042-T01 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T02 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T03 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T04 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T05 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T06 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T07 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | CUST-WP-0042-T08 | done | — | workplans/CUST-WP-0042-workplan-state-model-cleanup.md |
|
||||
| task | STATE-WP-0043-T01 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T02 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T03 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T04 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T05 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T06 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T07 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0043-T08 | done | — | workplans/STATE-WP-0043-dashboard-ui-experience.md |
|
||||
| task | STATE-WP-0044-T01 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| task | STATE-WP-0044-T02 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| task | STATE-WP-0044-T03 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| task | STATE-WP-0044-T04 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| task | STATE-WP-0044-T05 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| task | STATE-WP-0044-T06 | done | — | workplans/STATE-WP-0044-recently-on-scope-digest.md |
|
||||
| task | STATE-WP-0045-T01 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T02 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T03 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T04 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T05 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T06 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T07 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0045-T08 | done | — | workplans/STATE-WP-0045-token-measurement-accuracy.md |
|
||||
| task | STATE-WP-0046-T01 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| task | STATE-WP-0046-T02 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| task | STATE-WP-0046-T03 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| task | STATE-WP-0046-T04 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| task | STATE-WP-0046-T05 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| task | STATE-WP-0046-T06 | done | — | workplans/STATE-WP-0046-workplan-id-normalization.md |
|
||||
| task | STATE-WP-0047-T01 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0047-T02 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0047-T03 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0047-T04 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0047-T05 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0047-T06 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0047-T07 | done | — | workplans/STATE-WP-0047-lifecycle-assertions-and-renormalization.md |
|
||||
| task | STATE-WP-0048-T01 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0048-T02 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0048-T03 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0048-T04 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0048-T05 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0048-T06 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0048-T07 | done | — | workplans/STATE-WP-0048-ui-state-change-reconciliation.md |
|
||||
| task | STATE-WP-0049-T01 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0049-T02 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0049-T03 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0049-T04 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0049-T05 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0049-T06 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0049-T07 | done | — | workplans/STATE-WP-0049-workplan-execution-queue.md |
|
||||
| task | STATE-WP-0050-T01 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
|
||||
| task | STATE-WP-0050-T02 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
|
||||
| task | STATE-WP-0050-T03 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
|
||||
| task | STATE-WP-0050-T04 | done | — | workplans/STATE-WP-0050-railiance-fabric-graph-read-model-ingest.md |
|
||||
| task | STATE-WP-0051-T01 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| task | STATE-WP-0051-T02 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| task | STATE-WP-0051-T03 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| task | STATE-WP-0051-T04 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| task | STATE-WP-0051-T05 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| task | STATE-WP-0051-T06 | done | — | workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md |
|
||||
| task | STATE-WP-0052-T01 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T02 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T03 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T04 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T05 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T06 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T07 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T08 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T09 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0052-T10 | done | — | workplans/STATE-WP-0052-task-state-canon-adaptation.md |
|
||||
| task | STATE-WP-0053-T01 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| task | STATE-WP-0053-T02 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| task | STATE-WP-0053-T03 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| task | STATE-WP-0053-T04 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| task | STATE-WP-0053-T05 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| task | STATE-WP-0053-T06 | done | — | workplans/STATE-WP-0053-wsjf-triage-review-page.md |
|
||||
| task | STATE-WP-0054-T01 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T02 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T03 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T04 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T05 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T06 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T07 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0054-T08 | done | — | workplans/STATE-WP-0054-workplan-terminology-transition-legacy-meter.md |
|
||||
| task | STATE-WP-0055-T01 | done | — | workplans/STATE-WP-0055-statehub-register-cli.md |
|
||||
| task | STATE-WP-0056-T01 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T02 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T03 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T04 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T05 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T06 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T07 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0056-T08 | done | — | workplans/STATE-WP-0056-dashboard-loading-robustness.md |
|
||||
| task | STATE-WP-0057-T01 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| task | STATE-WP-0057-T02 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| task | STATE-WP-0057-T03 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| task | STATE-WP-0057-T04 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| task | STATE-WP-0057-T05 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| task | STATE-WP-0057-T06 | done | — | workplans/STATE-WP-0057-overview-workstream-stage-counts.md |
|
||||
| task | STATE-WP-0058-T01 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
|
||||
| task | STATE-WP-0058-T02 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
|
||||
| task | STATE-WP-0058-T03 | done | — | workplans/STATE-WP-0058-agent-skill-hub-interaction.md |
|
||||
| task | STATE-WP-0059-T01 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
|
||||
| task | STATE-WP-0059-T02 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
|
||||
| task | STATE-WP-0059-T03 | done | — | workplans/STATE-WP-0059-mcp-write-layer-reliability.md |
|
||||
| task | STATE-WP-0060-T01 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
|
||||
| task | STATE-WP-0060-T02 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
|
||||
| task | STATE-WP-0060-T03 | done | — | workplans/STATE-WP-0060-fix-consistency-cross-flavor.md |
|
||||
| task | STATE-WP-0062-T01 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
|
||||
| task | STATE-WP-0062-T02 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
|
||||
| task | STATE-WP-0062-T03 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
|
||||
| task | STATE-WP-0062-T04 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
|
||||
| task | STATE-WP-0062-T05 | done | — | workplans/STATE-WP-0062-service-catalog-two-dimensions.md |
|
||||
| task | STATE-WP-0063-T01 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
|
||||
| task | STATE-WP-0063-T02 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
|
||||
| task | STATE-WP-0063-T03 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
|
||||
| task | STATE-WP-0063-T04 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
|
||||
| task | STATE-WP-0063-T05 | done | — | workplans/STATE-WP-0063-weekend-automation-repair.md |
|
||||
| task | STATE-WP-0064-T01 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
|
||||
| task | STATE-WP-0064-T02 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
|
||||
| task | STATE-WP-0064-T03 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
|
||||
| task | STATE-WP-0064-T04 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
|
||||
| task | STATE-WP-0064-T05 | done | — | workplans/STATE-WP-0064-statehub-consistency-sync-railiance01.md |
|
||||
| task | STATE-WP-0065-T01 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
|
||||
| task | STATE-WP-0065-T02 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
|
||||
| task | STATE-WP-0065-T03 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
|
||||
| task | STATE-WP-0065-T04 | done | — | workplans/STATE-WP-0065-repo-anchored-classification-spine.md |
|
||||
| task | STATE-WP-0066-T01 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T02 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T03 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T04 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T05 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T06 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T07 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0066-T08 | done | — | workplans/STATE-WP-0066-state-summary-revision-cache.md |
|
||||
| task | STATE-WP-0067-T01 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| task | STATE-WP-0067-T02 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| task | STATE-WP-0067-T03 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| task | STATE-WP-0067-T04 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| task | STATE-WP-0067-T05 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| task | STATE-WP-0067-T06 | done | — | workplans/STATE-WP-0067-attached-repo-agent-normalization.md |
|
||||
| task | STATE-WP-0068-T01 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T02 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T03 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T04 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T05 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T06 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T07 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T08 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0068-T09 | done | — | workplans/STATE-WP-0068-offline-write-buffer-and-edge-relay.md |
|
||||
| task | STATE-WP-0071-T01 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| task | STATE-WP-0071-T02 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| task | STATE-WP-0071-T03 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| task | STATE-WP-0071-T04 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| task | STATE-WP-0071-T05 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| task | STATE-WP-0071-T06 | done | — | workplans/STATE-WP-0071-railiance01-workstream-caller-deploy.md |
|
||||
| task | STATE-WP-0072-T01 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
|
||||
| task | STATE-WP-0072-T02 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
|
||||
| task | STATE-WP-0072-T03 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
|
||||
| task | STATE-WP-0072-T04 | done | — | workplans/STATE-WP-0072-workstream-caller-remediation.md |
|
||||
| task | STATE-WP-0073-T01 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
|
||||
| task | STATE-WP-0073-T02 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
|
||||
| task | STATE-WP-0073-T03 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
|
||||
| task | STATE-WP-0073-T04 | done | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
|
||||
| task | STATE-WP-0073-T05 | cancel | — | workplans/STATE-WP-0073-legacy-meter-decline-and-remote-audit.md |
|
||||
| task | STATE-WP-0074-T01 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
|
||||
| task | STATE-WP-0074-T02 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
|
||||
| task | STATE-WP-0074-T03 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
|
||||
| task | STATE-WP-0074-T04 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
|
||||
| task | STATE-WP-0074-T05 | done | — | workplans/STATE-WP-0074-hub-ecosystem-boundary-alignment.md |
|
||||
| task | STATE-WP-0075-T01 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
|
||||
| task | STATE-WP-0075-T02 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
|
||||
| task | STATE-WP-0075-T03 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
|
||||
| task | STATE-WP-0075-T04 | done | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
|
||||
| task | STATE-WP-0076-T01 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| task | STATE-WP-0076-T02 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| task | STATE-WP-0076-T03 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| task | STATE-WP-0076-T04 | done | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| task | STATE-WP-0076-T05 | cancel | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| task | STATE-WP-0076-T06 | cancel | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
|
||||
| task | STATE-WP-0077-T01 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
|
||||
| task | STATE-WP-0077-T02 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
|
||||
| task | STATE-WP-0077-T03 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
|
||||
| task | STATE-WP-0077-T04 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
|
||||
| task | STATE-WP-0078-T01 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
|
||||
| task | STATE-WP-0078-T02 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
|
||||
| task | STATE-WP-0078-T03 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
|
||||
| task | STATE-WP-0079-T01 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T02 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T03 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T04 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T05 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T06 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T07 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T08 | done | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0079-T09 | wait | — | workplans/STATE-WP-0079-retirement-strangler.md |
|
||||
| task | STATE-WP-0080-T01 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T02 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T03 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T04 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T05 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T06 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T07 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0080-T08 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md |
|
||||
| task | STATE-WP-0081-T01 | cancel | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T02 | cancel | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T03 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T04 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T05 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T06 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T07 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0081-T08 | done | — | workplans/STATE-WP-0081-cluster-self-sufficiency-and-registrar.md |
|
||||
| task | STATE-WP-0082-T01 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| task | STATE-WP-0082-T02 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| task | STATE-WP-0082-T03 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| task | STATE-WP-0082-T04 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| task | STATE-WP-0082-T05 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| task | STATE-WP-0082-T06 | done | — | workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md |
|
||||
| task | STATE-WP-0083-T01 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T02 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T03 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T04 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T05 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T06 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T07 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0083-T08 | done | — | workplans/STATE-WP-0083-forge-derived-projection-reset.md |
|
||||
| task | STATE-WP-0084-T01 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0084-T02 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0084-T03 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0084-T04 | done | — | workplans/STATE-WP-0084-forge-read-for-private-repositories.md |
|
||||
| task | STATE-WP-0085-T01 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T05 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T06 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T07 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T08 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T09 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0086-T01 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
|
||||
| task | STATE-WP-0086-T02 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
|
||||
| task | STATE-WP-0086-T03 | done | — | workplans/STATE-WP-0086-fast-forge-projection-reconcile.md |
|
||||
| task | STATE-WP-0087-T01 | done | — | workplans/STATE-WP-0087-bounded-repository-collection-projection.md |
|
||||
| task | STATE-WP-0088-T01 | done | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md |
|
||||
| task | STATE-WP-0088-T02 | done | — | workplans/STATE-WP-0088-preflight-signing-runtime-acceptance.md |
|
||||
| task | STATE-WP-0089-T01 | done | — | workplans/STATE-WP-0089-rename-redirect-recovery.md |
|
||||
| task | STATE-WP-0089-T02 | done | — | workplans/STATE-WP-0089-rename-redirect-recovery.md |
|
||||
| task | STATE-WP-0090-T01 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
|
||||
| task | STATE-WP-0090-T02 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
|
||||
| task | STATE-WP-0090-T03 | done | — | workplans/STATE-WP-0090-projection-task-field-convergence.md |
|
||||
| task | STATE-WP-0091-T01 | done | — | workplans/STATE-WP-0091-release-headroom.md |
|
||||
| task | STATE-WP-0091-T02 | done | — | workplans/STATE-WP-0091-release-headroom.md |
|
||||
| task | STATE-WP-0092-T01 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
|
||||
| task | STATE-WP-0092-T02 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
|
||||
| task | STATE-WP-0092-T03 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
|
||||
| task | STATE-WP-0092-T04 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
|
||||
| task | STATE-WP-0092-T05 | done | — | workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md |
|
||||
|
|
@ -9,8 +9,16 @@ import re
|
|||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
# Explicit override first — every other candidate below is a developer
|
||||
# workstation path, so in a container none of them exist and classification
|
||||
# validation fails outright. Set REPO_CLASSIFICATION_ALLOWED_PATH in any
|
||||
# deployment that does not carry a the-custodian checkout (CUST-WP-0067-T04).
|
||||
_ENV_ALLOWED = os.environ.get("REPO_CLASSIFICATION_ALLOWED_PATH")
|
||||
|
||||
# Workstation checkout, railiance01 fleet checkout, then state-hub sibling fallback.
|
||||
_PRIMARY_ALLOWED = Path(
|
||||
"/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"
|
||||
|
|
@ -70,12 +78,21 @@ class ClassificationData:
|
|||
|
||||
|
||||
def _allowed_path() -> Path:
|
||||
if _ENV_ALLOWED:
|
||||
candidate = Path(_ENV_ALLOWED)
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
f"REPO_CLASSIFICATION_ALLOWED_PATH is set to {candidate}, which is not a file"
|
||||
)
|
||||
for candidate in (_PRIMARY_ALLOWED, _RAILIANCE_ALLOWED, _FALLBACK_ALLOWED):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
"repo-classification.allowed.yaml not found at "
|
||||
f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}"
|
||||
f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}. "
|
||||
"Set REPO_CLASSIFICATION_ALLOWED_PATH when running without a "
|
||||
"the-custodian checkout (for example in a container)."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
|
|
@ -10,9 +12,36 @@ class Settings(BaseSettings):
|
|||
|
||||
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
|
||||
api_base: str = "http://127.0.0.1:8000"
|
||||
# What this instance claims to be. Deliberately defaults to "unknown":
|
||||
# an instance that has not declared itself is not the primary, and callers
|
||||
# that need the authoritative hub must be able to tell the difference.
|
||||
# Answering on a port is not evidence of authority — that assumption cost
|
||||
# seven weeks of onboarding (CUST-WP-0067-T03, ADR-010).
|
||||
state_hub_instance_role: Literal["primary", "cache", "unknown"] = "unknown"
|
||||
# Env vars are STATE_HUB_INSTANCE_ROLE / STATE_HUB_INSTANCE_LABEL — the
|
||||
# field name *is* the binding, so renaming either breaks the deployment
|
||||
# silently. Covered by tests/test_instance_identity.py.
|
||||
# Free-form label to name *which* instance answered, e.g. "railiance01".
|
||||
state_hub_instance_label: str | None = None
|
||||
debug: bool = False
|
||||
state_hub_report_dir: str = "reports/recently-on-scope"
|
||||
state_hub_markitect_cli_path: str | None = None
|
||||
activity_core_url: str | None = None
|
||||
activity_core_worker_token: str | None = None
|
||||
ops_run_projection_ttl_seconds: float = 15.0
|
||||
ops_run_sla_hours: float = 1.0
|
||||
sbom_nexus_url: str | None = None
|
||||
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_timeout_seconds: float = 5.0
|
||||
# Repository renames are central-only, compare-and-set operations. The
|
||||
# secret signs short-lived, non-persistent preflight evidence; it must be
|
||||
# supplied by the deployment (normally through OpenBao), never recorded in
|
||||
# State Hub. With no secret the read-only report remains available but no
|
||||
# mutation token can be issued.
|
||||
repository_rename_preflight_secret: str | None = None
|
||||
repository_rename_preflight_ttl_seconds: int = 900
|
||||
repository_rename_forge_timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
13
api/main.py
13
api/main.py
|
|
@ -12,7 +12,7 @@ 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, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
|
|
@ -23,6 +23,10 @@ from api.routers import reconciliation
|
|||
from api.routers import execution
|
||||
from api.routers import fabric
|
||||
from api.routers import legacy_meter
|
||||
from api.routers import review_contracts
|
||||
from api.routers import identifier_migrations
|
||||
from api.routers import repository_renames
|
||||
from api.routers import work_record_projection
|
||||
|
||||
|
||||
class ETagMiddleware(BaseHTTPMiddleware):
|
||||
|
|
@ -107,6 +111,9 @@ 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(repository_renames.router)
|
||||
app.include_router(repository_renames.operation_router)
|
||||
app.include_router(work_record_projection.router)
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(workstreams.workplan_router)
|
||||
|
|
@ -114,6 +121,7 @@ app.include_router(workstream_dependencies.router)
|
|||
app.include_router(workstream_dependencies.workplan_router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(intake.router)
|
||||
app.include_router(extension_points.router)
|
||||
app.include_router(technical_debt.router)
|
||||
app.include_router(progress.router)
|
||||
|
|
@ -133,7 +141,10 @@ app.include_router(reconciliation.router)
|
|||
app.include_router(execution.router)
|
||||
app.include_router(fabric.router)
|
||||
app.include_router(legacy_meter.router)
|
||||
app.include_router(review_contracts.router)
|
||||
app.include_router(identifier_migrations.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(ops_runs.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ from api.models.workstream import Workstream
|
|||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.intake import (
|
||||
Intake,
|
||||
IntakeNote,
|
||||
IntakeLane,
|
||||
IntakeStatus,
|
||||
IntakeOutcome,
|
||||
)
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.extension_point import ExtensionPoint, EPStatus
|
||||
from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||
|
|
@ -34,6 +41,13 @@ from api.models.workplan_launch_request import WorkplanLaunchRequest
|
|||
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
||||
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
||||
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||
from api.models.repository_rename import (
|
||||
RepositoryForgeIdentity,
|
||||
RepositoryRenameOperation,
|
||||
RepositorySlug,
|
||||
)
|
||||
from api.models.suggestion import (
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
|
|
@ -54,6 +68,7 @@ __all__ = [
|
|||
"WorkstreamDependency",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"Intake", "IntakeNote", "IntakeLane", "IntakeStatus", "IntakeOutcome",
|
||||
"ProgressEvent",
|
||||
"ExtensionPoint", "EPStatus",
|
||||
"TechnicalDebt", "TDStatus",
|
||||
|
|
@ -73,5 +88,8 @@ __all__ = [
|
|||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||
"WriteIdempotencyKey",
|
||||
"WorkRecordIdentifierAlias",
|
||||
"ReviewContract", "ReviewReceipt",
|
||||
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
|
||||
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
|
@ -24,3 +26,28 @@ class TimestampMixin:
|
|||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def new_uuid7() -> uuid.UUID:
|
||||
"""Generate a UUIDv7 (RFC 9562): 48-bit big-endian ms timestamp, version
|
||||
and variant bits, remaining bits random. Time-sortable, so primary keys
|
||||
generated with this helper order chronologically without a separate
|
||||
created_at index lookup — the identity layering canon
|
||||
(work-record-types_v0.1.md) calls this out as the primary internal key
|
||||
for new work-record entities.
|
||||
|
||||
Dependency-free (no uuid7 in stdlib before Python 3.14, no third-party
|
||||
lib added for a ~15-line, non-cryptographic layout).
|
||||
"""
|
||||
unix_ts_ms = int(time.time() * 1000)
|
||||
rand = int.from_bytes(os.urandom(10), "big")
|
||||
rand_a = (rand >> 62) & 0x0FFF # top 12 bits of the 80 random bits
|
||||
rand_b = rand & 0x3FFFFFFFFFFFFFFF # bottom 62 bits
|
||||
value = (
|
||||
(unix_ts_ms << 80)
|
||||
| (0x7 << 76) # version 7
|
||||
| (rand_a << 64)
|
||||
| (0x2 << 62) # variant 10
|
||||
| rand_b
|
||||
)
|
||||
return uuid.UUID(int=value)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||
)
|
||||
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
|
@ -47,7 +47,7 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||
)
|
||||
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL"),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
|
@ -55,7 +55,7 @@ class CapabilityRequest(Base, TimestampMixin):
|
|||
# Links
|
||||
blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import enum
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -48,7 +48,9 @@ class Contribution(Base, TimestampMixin):
|
|||
UUID(as_uuid=True), ForeignKey("topics.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
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ class Decision(Base, TimestampMixin):
|
|||
UUID(as_uuid=True), ForeignKey("topics.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
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ class ExtensionPoint(Base, TimestampMixin):
|
|||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class FabricGraphImport(Base, TimestampMixin):
|
|||
)
|
||||
source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_commit: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
source_commit: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
source_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
api_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
schema_version: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
|
|
|
|||
129
api/models/intake.py
Normal file
129
api/models/intake.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid7
|
||||
|
||||
|
||||
class IntakeLane(str, enum.Enum):
|
||||
green = "green"
|
||||
blue = "blue"
|
||||
yellow = "yellow"
|
||||
orange = "orange"
|
||||
red = "red"
|
||||
|
||||
|
||||
class IntakeStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
vetted = "vetted"
|
||||
routed = "routed"
|
||||
closed = "closed"
|
||||
|
||||
|
||||
class IntakeOutcome(str, enum.Enum):
|
||||
promoted = "promoted"
|
||||
declined = "declined"
|
||||
absorbed = "absorbed"
|
||||
|
||||
|
||||
OPEN_INTAKE_STATUSES = (IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed)
|
||||
|
||||
|
||||
class Intake(Base, TimestampMixin):
|
||||
"""A `kind: intake` work record — a spark: idea, finding, directive, or
|
||||
request, per canon/standards/work-record-types_v0.1.md. Lifecycle:
|
||||
open -> vetted -> routed -> closed(promoted|declined|absorbed).
|
||||
|
||||
Fresh entity per the founder-reviewed architecture draft (2026-07-20,
|
||||
WorkOrchestrationArchitectureDraft.md §8 item 6): not a rename/reuse of
|
||||
the legacy `suggestions` table.
|
||||
"""
|
||||
|
||||
__tablename__ = "intakes"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workplan_id IS NOT NULL OR repo_id IS NOT NULL",
|
||||
name="ck_intakes_topic_or_workplan_or_repo",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(status != 'closed') OR (outcome IS NOT NULL)",
|
||||
name="ck_intakes_closed_requires_outcome",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(outcome != 'promoted') OR (promoted_to IS NOT NULL)",
|
||||
name="ck_intakes_promoted_requires_promoted_to",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid7
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.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", onupdate="CASCADE"),
|
||||
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
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
lane: Mapped[IntakeLane] = mapped_column(
|
||||
Enum(IntakeLane, name="intakelane"), nullable=False, default=IntakeLane.green
|
||||
)
|
||||
status: Mapped[IntakeStatus] = mapped_column(
|
||||
Enum(IntakeStatus, name="intakestatus"),
|
||||
nullable=False,
|
||||
default=IntakeStatus.open,
|
||||
index=True,
|
||||
)
|
||||
outcome: Mapped[IntakeOutcome | None] = mapped_column(
|
||||
Enum(IntakeOutcome, name="intakeoutcome"), nullable=True
|
||||
)
|
||||
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
promoted_to: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
source_repo_path: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, doc="Repo-relative path of the source file this record was authored in."
|
||||
)
|
||||
routed_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["IntakeNote"]] = relationship(
|
||||
"IntakeNote",
|
||||
back_populates="intake",
|
||||
lazy="selectin",
|
||||
order_by="IntakeNote.created_at",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class IntakeNote(Base):
|
||||
__tablename__ = "intake_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid7)
|
||||
intake_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("intakes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
intake: Mapped["Intake"] = relationship("Intake", back_populates="notes")
|
||||
|
|
@ -53,6 +53,21 @@ class ManagedRepo(Base, TimestampMixin):
|
|||
"RepoGoal", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821
|
||||
"RepositoryForgeIdentity",
|
||||
back_populates="repo",
|
||||
lazy="selectin",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821
|
||||
"RepositorySlug", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821
|
||||
"RepositoryRenameOperation", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
|
|
|||
|
|
@ -20,10 +20,16 @@ class ProgressEvent(Base):
|
|||
UUID(as_uuid=True), ForeignKey("topics.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
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
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
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
|
|
|
|||
292
api/models/repository_rename.py
Normal file
292
api/models/repository_rename.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
FORGE_IDENTITY_STATES = ("unverified", "verified")
|
||||
REPOSITORY_SLUG_KINDS = ("canonical", "alias")
|
||||
REPOSITORY_RENAME_PHASES = (
|
||||
"draft",
|
||||
"preflighted",
|
||||
"forge-renamed",
|
||||
"statehub-rebound",
|
||||
"source-synced",
|
||||
"consumers-verified",
|
||||
"completed",
|
||||
"rollback-preflight",
|
||||
"rolled-back",
|
||||
)
|
||||
TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back")
|
||||
|
||||
|
||||
class RepositoryForgeIdentity(Base, TimestampMixin):
|
||||
"""One explicitly verified (or explicitly unverified) Forge identity per repo."""
|
||||
|
||||
__tablename__ = "repository_forge_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"),
|
||||
UniqueConstraint(
|
||||
"id",
|
||||
"repo_id",
|
||||
"verification_state",
|
||||
name="uq_repository_forge_identity_verified_ref",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"provider",
|
||||
"forge_instance",
|
||||
"forge_owner",
|
||||
"forge_repository_id",
|
||||
name="uq_repository_forge_identity_tuple",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state IN ('unverified', 'verified')",
|
||||
name="ck_repository_forge_identity_state",
|
||||
),
|
||||
CheckConstraint(
|
||||
"forge_repository_id IS NULL OR forge_repository_id > 0",
|
||||
name="ck_repository_forge_id_positive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state != 'verified' OR "
|
||||
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
|
||||
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
|
||||
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
|
||||
name="ck_repository_forge_verified_complete",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state = 'verified' OR "
|
||||
"(verified_at IS NULL AND verified_by IS NULL)",
|
||||
name="ck_repository_forge_unverified_has_no_attestation",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
provider: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
verification_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="unverified", server_default="unverified"
|
||||
)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
verification_evidence: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="forge_identity", lazy="selectin"
|
||||
)
|
||||
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship(
|
||||
"RepositoryRenameOperation",
|
||||
back_populates="forge_identity",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryRenameOperation(Base, TimestampMixin):
|
||||
"""Append-preserving journal for one phased canonical-coordinate change."""
|
||||
|
||||
__tablename__ = "repository_rename_operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"phase IN ('draft', 'preflighted', 'forge-renamed', "
|
||||
"'statehub-rebound', 'source-synced', 'consumers-verified', "
|
||||
"'completed', 'rollback-preflight', 'rolled-back')",
|
||||
name="ck_repository_rename_phase",
|
||||
),
|
||||
CheckConstraint(
|
||||
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
|
||||
),
|
||||
CheckConstraint(
|
||||
"expected_forge_repository_id > 0",
|
||||
name="ck_repository_rename_forge_id_positive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"forge_identity_state = 'verified'",
|
||||
name="ck_repository_rename_verified_identity",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase = 'draft' OR preflighted_at IS NOT NULL",
|
||||
name="ck_repository_rename_preflight_timestamp",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase != 'completed' OR completed_at IS NOT NULL",
|
||||
name="ck_repository_rename_completed_timestamp",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
|
||||
name="ck_repository_rename_rollback_timestamp",
|
||||
),
|
||||
Index(
|
||||
"uq_repository_rename_active_repo",
|
||||
"repo_id",
|
||||
unique=True,
|
||||
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
),
|
||||
Index(
|
||||
"uq_repository_rename_active_new_slug",
|
||||
"new_slug",
|
||||
unique=True,
|
||||
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
),
|
||||
Index(
|
||||
"ix_repository_rename_phase", "phase"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["forge_identity_id", "repo_id", "forge_identity_state"],
|
||||
[
|
||||
"repository_forge_identities.id",
|
||||
"repository_forge_identities.repo_id",
|
||||
"repository_forge_identities.verification_state",
|
||||
],
|
||||
name="fk_repository_rename_verified_identity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
forge_identity_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=False
|
||||
)
|
||||
forge_identity_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="verified", server_default="verified"
|
||||
)
|
||||
expected_provider: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expected_forge_repository_id: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False
|
||||
)
|
||||
expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
phase: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="draft", server_default="draft"
|
||||
)
|
||||
actor: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
phase_changed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
preflighted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
preflight_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
evidence: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
error_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
error_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="rename_operations", lazy="selectin"
|
||||
)
|
||||
forge_identity: Mapped[RepositoryForgeIdentity] = relationship(
|
||||
"RepositoryForgeIdentity",
|
||||
back_populates="rename_operations",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
)
|
||||
slugs: Mapped[list["RepositorySlug"]] = relationship(
|
||||
"RepositorySlug", back_populates="source_operation", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class RepositorySlug(Base, TimestampMixin):
|
||||
"""Global uniqueness boundary for current and protected prior slugs."""
|
||||
|
||||
__tablename__ = "repository_slugs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slug", name="uq_repository_slugs_slug"),
|
||||
CheckConstraint(
|
||||
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
|
||||
),
|
||||
CheckConstraint(
|
||||
"kind != 'alias' OR protected",
|
||||
name="ck_repository_slug_alias_protected",
|
||||
),
|
||||
Index(
|
||||
"uq_repository_slugs_one_canonical",
|
||||
"repo_id",
|
||||
unique=True,
|
||||
postgresql_where=text("kind = 'canonical'"),
|
||||
),
|
||||
Index("ix_repository_slugs_repo_kind", "repo_id", "kind"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
protected: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
source_operation_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="slug_records", lazy="selectin"
|
||||
)
|
||||
source_operation: Mapped[RepositoryRenameOperation | None] = relationship(
|
||||
"RepositoryRenameOperation", back_populates="slugs", lazy="selectin"
|
||||
)
|
||||
128
api/models/review_contract.py
Normal file
128
api/models/review_contract.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid7
|
||||
|
||||
|
||||
class ReviewContract(Base, TimestampMixin):
|
||||
"""Replaceable projection of one authoritative review-contract revision."""
|
||||
|
||||
__tablename__ = "review_contracts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid7
|
||||
)
|
||||
contract_key: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
schema_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
contract_digest: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, unique=True
|
||||
)
|
||||
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, index=True
|
||||
)
|
||||
required_for_decision: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("decisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
projected_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
receipts: Mapped[list["ReviewReceipt"]] = relationship(
|
||||
"ReviewReceipt", back_populates="contract", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"contract_key",
|
||||
"source_repo",
|
||||
"source_path",
|
||||
"source_revision",
|
||||
name="uq_review_contract_source_revision",
|
||||
),
|
||||
Index("ix_review_contract_key_active", "contract_key", "active"),
|
||||
)
|
||||
|
||||
|
||||
class ReviewReceipt(Base, TimestampMixin):
|
||||
"""Immutable evidence submitted against an exact contract digest."""
|
||||
|
||||
__tablename__ = "review_receipts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid7
|
||||
)
|
||||
contract_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("review_contracts.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
disposition: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
contract_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
receipt_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
artifact_hashes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
assertion_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
checks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
submitted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
|
||||
contract: Mapped[ReviewContract] = relationship(
|
||||
"ReviewContract", back_populates="receipts"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_repo",
|
||||
"source_path",
|
||||
"source_revision",
|
||||
name="uq_review_receipt_source_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_review_receipt_contract_owner_time",
|
||||
"contract_id",
|
||||
"owner_id",
|
||||
"submitted_at",
|
||||
),
|
||||
)
|
||||
|
|
@ -36,7 +36,9 @@ class Suggestion(Base, TimestampMixin):
|
|||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
@ -61,7 +63,9 @@ class Suggestion(Base, TimestampMixin):
|
|||
Float, nullable=False, default=1.0, server_default="1"
|
||||
)
|
||||
promoted_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
|
|
@ -117,4 +121,4 @@ class SuggestionRelevanceBump(Base):
|
|||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,18 @@ class Task(Base, TimestampMixin):
|
|||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Canonical work-record identifier, e.g. "CUST-WP-0067-T01" (ADR-007).
|
||||
# Nullable because rows created before STATE-WP-0083-T06 have none; only the
|
||||
# repository files know the mapping. Without it a task can be matched only by
|
||||
# title, so renaming a heading looks like one task vanishing and another
|
||||
# appearing.
|
||||
record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
|
|
@ -47,7 +57,9 @@ class Task(Base, TimestampMixin):
|
|||
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821
|
||||
|
|
|
|||
|
|
@ -77,7 +77,9 @@ class TechnicalDebt(Base, TimestampMixin):
|
|||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="SET NULL"), nullable=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, Text, UniqueConstraint, func
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -25,10 +32,16 @@ class TokenEvent(Base):
|
|||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
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
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
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
|
||||
|
|
|
|||
44
api/models/work_record_identifier_alias.py
Normal file
44
api/models/work_record_identifier_alias.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkRecordIdentifierAlias(Base, TimestampMixin):
|
||||
"""Durable provenance for a canonical work-record UUID replacement."""
|
||||
|
||||
__tablename__ = "work_record_identifier_aliases"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"record_kind IN ('workplan', 'task')",
|
||||
name="ck_work_record_identifier_aliases_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"migration_status IN ('prepared', 'applied', 'reversed')",
|
||||
name="ck_work_record_identifier_aliases_status",
|
||||
),
|
||||
UniqueConstraint("old_id", name="uq_work_record_identifier_aliases_old_id"),
|
||||
UniqueConstraint("new_id", name="uq_work_record_identifier_aliases_new_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
old_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
new_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
record_kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
record_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
namespace: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
plan_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
migration_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="prepared", server_default="prepared"
|
||||
)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reversed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
@ -39,6 +39,9 @@ class Workplan(Base, TimestampMixin):
|
|||
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)
|
||||
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
|
|
@ -53,6 +56,16 @@ class Workplan(Base, TimestampMixin):
|
|||
index=True,
|
||||
)
|
||||
backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Projection provenance and retirement (ADR-012 decisions 2 and 7).
|
||||
# A retired record is one the forge no longer derives. It is not deleted:
|
||||
# hub-native records reference it with RESTRICT and must survive. It is not
|
||||
# `archived` either — that says the owner closed the work, which is a
|
||||
# different fact.
|
||||
projection_retired_at: Mapped["datetime | None"] = mapped_column( # noqa: F821
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
projection_retired_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
derived_from_commit: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
backing_archived: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
backing_synced_at: Mapped[datetime | None] = mapped_column(
|
||||
|
|
|
|||
|
|
@ -45,19 +45,19 @@ class WorkplanDependency(Base, TimestampMixin):
|
|||
)
|
||||
from_workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
|
@ -72,4 +72,4 @@ class WorkplanDependency(Base, TimestampMixin):
|
|||
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
|
||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class WorkplanLaunchRequest(Base, TimestampMixin):
|
|||
)
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE"),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -11,7 +11,6 @@ from api.models.agent_message import AgentMessage
|
|||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.services.suggestion_relevance import bump_matching_for_capability_request
|
||||
from api.schemas.capability_request import (
|
||||
|
|
@ -22,12 +21,15 @@ from api.schemas.capability_request import (
|
|||
CapabilityRequestRead,
|
||||
CapabilityRequestReroute,
|
||||
CapabilityRequestStatusPatch,
|
||||
CatalogCreate,
|
||||
CatalogPatch,
|
||||
CatalogRead,
|
||||
)
|
||||
from hub_core.routers.capabilities import (
|
||||
create_capability_catalog_router,
|
||||
create_capability_request_read_router,
|
||||
create_capability_request_write_router,
|
||||
)
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -394,12 +396,102 @@ def _check_transition(current: str, target: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
router = create_capability_catalog_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
repo_model=ManagedRepo,
|
||||
catalog_model=CapabilityCatalog,
|
||||
router = APIRouter(tags=["capability-requests"])
|
||||
|
||||
|
||||
async def _catalog_domain(slug: str, session: AsyncSession) -> Domain:
|
||||
domain = (
|
||||
await session.execute(select(Domain).where(Domain.slug == slug))
|
||||
).scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||
return domain
|
||||
|
||||
|
||||
@router.post(
|
||||
"/capability-catalog/",
|
||||
response_model=CatalogRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_catalog_entry(
|
||||
body: CatalogCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
domain = await _catalog_domain(body.domain, session)
|
||||
repo_id = None
|
||||
if body.repo_slug:
|
||||
repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
|
||||
entry = CapabilityCatalog(
|
||||
domain_id=domain.id,
|
||||
repo_id=repo_id,
|
||||
capability_type=body.capability_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
keywords=body.keywords,
|
||||
)
|
||||
session.add(entry)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
|
||||
f"already exists in domain '{body.domain}'"
|
||||
),
|
||||
)
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
||||
async def list_catalog(
|
||||
domain: str | None = Query(None),
|
||||
capability_type: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
repo_slug: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityCatalog]:
|
||||
query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
||||
if domain:
|
||||
query = query.where(
|
||||
CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id
|
||||
)
|
||||
if capability_type:
|
||||
query = query.where(CapabilityCatalog.capability_type == capability_type)
|
||||
if repo_slug:
|
||||
query = query.where(
|
||||
CapabilityCatalog.repo_id
|
||||
== (await resolve_repository_slug(session, repo_slug)).repo.id
|
||||
)
|
||||
if status_filter and status_filter != "all":
|
||||
query = query.where(CapabilityCatalog.status == status_filter)
|
||||
elif not status_filter:
|
||||
query = query.where(CapabilityCatalog.status == "active")
|
||||
return list((await session.execute(query)).scalars().all())
|
||||
|
||||
|
||||
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
|
||||
async def patch_catalog_entry(
|
||||
entry_id: uuid.UUID,
|
||||
body: CatalogPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
entry = await session.get(CapabilityCatalog, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||
if body.repo_slug is not None:
|
||||
entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
|
||||
for field in ("description", "keywords", "status"):
|
||||
value = getattr(body, field)
|
||||
if value is not None:
|
||||
setattr(entry, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
router.include_router(
|
||||
create_capability_request_read_router(
|
||||
get_session,
|
||||
|
|
@ -432,4 +524,4 @@ router.include_router(
|
|||
after_dispute=_notify_on_dispute,
|
||||
after_reroute=_notify_on_reroute,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from pathlib import Path
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -14,9 +13,12 @@ from api.database import get_session
|
|||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.review_contract import ReviewContract
|
||||
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
|
||||
from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param
|
||||
from api.services.review_contracts import aggregate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/decisions", tags=["decisions"])
|
||||
|
||||
_FINANCIAL_LEGAL_KEYWORDS = (
|
||||
|
|
@ -154,6 +156,25 @@ async def resolve_decision_action(
|
|||
if decision.status == DecisionStatus.resolved:
|
||||
raise HTTPException(status_code=409, detail="Decision already resolved")
|
||||
|
||||
review_rows = await session.execute(
|
||||
select(ReviewContract).where(
|
||||
ReviewContract.decision_id == decision.id,
|
||||
ReviewContract.active.is_(True),
|
||||
ReviewContract.required_for_decision.is_(True),
|
||||
)
|
||||
)
|
||||
for contract in review_rows.scalars():
|
||||
review_state = await aggregate(session, contract)
|
||||
if not review_state.satisfied:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "required multi-owner review is not satisfied",
|
||||
"contract_key": contract.contract_key,
|
||||
"contract_digest": contract.contract_digest,
|
||||
},
|
||||
)
|
||||
|
||||
decision.status = DecisionStatus.resolved
|
||||
decision.decision_type = DecisionType.made
|
||||
decision.rationale = body.rationale
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ from api.services.execution_queue import (
|
|||
ACTIVITY_CORE_RESPONSIBILITIES,
|
||||
CONCURRENCY_MODES,
|
||||
EXECUTION_STATES,
|
||||
EXECUTION_REPLACEMENTS,
|
||||
LAUNCH_MODES,
|
||||
STATE_HUB_RESPONSIBILITIES,
|
||||
execution_state_for_launch,
|
||||
queue_sort_key,
|
||||
workplan_blockers,
|
||||
)
|
||||
|
|
@ -43,6 +43,8 @@ async def execution_semantics() -> ExecutionSemantics:
|
|||
concurrency_modes=CONCURRENCY_MODES,
|
||||
state_hub_responsibility=STATE_HUB_RESPONSIBILITIES,
|
||||
activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES,
|
||||
launch_requests_accepted=False,
|
||||
replacements=EXECUTION_REPLACEMENTS,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -161,37 +163,27 @@ async def workplan_stack(
|
|||
|
||||
@router.post(
|
||||
"/launch-requests",
|
||||
response_model=LaunchRequestRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
status_code=status.HTTP_410_GONE,
|
||||
)
|
||||
async def create_launch_request(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: LaunchRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkplanLaunchRequest:
|
||||
ws = await session.get(Workplan, body.workplan_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
launch_request = WorkplanLaunchRequest(
|
||||
workplan_id=ws.id,
|
||||
requested_by=body.requested_by,
|
||||
requested_actor=body.requested_actor,
|
||||
launch_mode=body.launch_mode,
|
||||
concurrency_mode=body.concurrency_mode,
|
||||
priority=body.priority or ws.planning_priority,
|
||||
repo_id=body.repo_id or ws.repo_id,
|
||||
branch_preference=body.branch_preference,
|
||||
immediate_pickup=body.immediate_pickup,
|
||||
notes=body.notes,
|
||||
request_metadata=body.request_metadata,
|
||||
) -> None:
|
||||
del body # Request shape stays documented while the retired route returns 410.
|
||||
await retire_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
interface_key="rest_api:POST /execution/launch-requests",
|
||||
replacement_ref="repo file queue or activity-core ActivityDefinition + ops_run",
|
||||
detail=(
|
||||
"State Hub workplan launch requests are retired: no consumer picks up these rows. "
|
||||
"Queue development work in the authoritative repository file; use an "
|
||||
"ActivityDefinition and activity-core ops_run only for recurring or operational fires."
|
||||
),
|
||||
)
|
||||
ws.launch_mode = body.launch_mode
|
||||
ws.concurrency_mode = body.concurrency_mode
|
||||
ws.execution_state = execution_state_for_launch(body.launch_mode, body.immediate_pickup)
|
||||
session.add(launch_request)
|
||||
await session.commit()
|
||||
await session.refresh(launch_request)
|
||||
return launch_request
|
||||
|
||||
|
||||
@router.get("/launch-requests", response_model=list[LaunchRequestRead])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -10,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from api.database import get_session
|
||||
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
|
||||
from api.schemas.fabric_graph import (
|
||||
FabricGraphActivationRequest,
|
||||
FabricGraphActivationResult,
|
||||
FabricGraphEdgeRead,
|
||||
FabricGraphImportRead,
|
||||
FabricGraphIngestResult,
|
||||
|
|
@ -19,10 +22,12 @@ from api.schemas.fabric_graph import (
|
|||
)
|
||||
from api.services.fabric_graph import (
|
||||
FabricGraphValidationError,
|
||||
activate_fabric_graph_import,
|
||||
ingest_fabric_graph_export,
|
||||
record_fabric_graph_error,
|
||||
split_graph_ingest_body,
|
||||
)
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/fabric", tags=["fabric"])
|
||||
|
||||
|
|
@ -131,6 +136,34 @@ async def latest_graph_import(
|
|||
return FabricGraphImportRead.model_validate(import_run)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/graph-exports/{import_id}/activate",
|
||||
response_model=FabricGraphActivationResult,
|
||||
)
|
||||
async def activate_graph_import(
|
||||
import_id: uuid.UUID,
|
||||
body: FabricGraphActivationRequest | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FabricGraphActivationResult:
|
||||
request = body or FabricGraphActivationRequest()
|
||||
try:
|
||||
import_run, previous_import_id, activated = await activate_fabric_graph_import(
|
||||
session,
|
||||
import_id,
|
||||
requested_by=request.requested_by,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if import_run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Fabric graph import '{import_id}' not found")
|
||||
return FabricGraphActivationResult(
|
||||
import_run=FabricGraphImportRead.model_validate(import_run),
|
||||
previous_import_id=previous_import_id,
|
||||
activated=activated,
|
||||
idempotent=not activated,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/graph/nodes", response_model=list[FabricGraphNodeRead])
|
||||
async def list_graph_nodes(
|
||||
source_repo_slug: str = "railiance-fabric",
|
||||
|
|
@ -157,7 +190,12 @@ async def list_graph_nodes(
|
|||
if domain:
|
||||
query = query.where(FabricGraphNode.domain_slug == domain)
|
||||
if repo:
|
||||
query = query.where(FabricGraphNode.repo_slug == repo)
|
||||
resolution = await resolve_repository_slug(session, repo, required=False)
|
||||
query = query.where(
|
||||
FabricGraphNode.repo_slug.in_(
|
||||
resolution.slug_values if resolution else (repo,)
|
||||
)
|
||||
)
|
||||
if canonical_category:
|
||||
query = query.where(FabricGraphNode.canon_category == canonical_category)
|
||||
if fabric_id:
|
||||
|
|
|
|||
118
api/routers/identifier_migrations.py
Normal file
118
api/routers/identifier_migrations.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.config import settings
|
||||
from api.schemas.identifier_migration import (
|
||||
IdentifierMigrationApplySubmit,
|
||||
SealedProjectionRepairReceiptRead,
|
||||
SealedProjectionRepairSubmit,
|
||||
)
|
||||
from api.services.work_record_identifier_migration import (
|
||||
IdentifierMigrationError,
|
||||
apply_repository_identifier_migration,
|
||||
repair_absent_prederivation_projection,
|
||||
reverse_repository_identifier_migration,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/identifier-migrations", tags=["identifier-migrations"])
|
||||
|
||||
|
||||
@router.post("/repositories/{repo_slug}/apply")
|
||||
async def apply_identifier_migration(
|
||||
repo_slug: str,
|
||||
body: IdentifierMigrationApplySubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Apply one sealed repository-atomic identifier convergence transaction."""
|
||||
if settings.state_hub_instance_role != "primary":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "identifier migration writes require the primary State Hub",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
},
|
||||
)
|
||||
if not body.primary_confirmed:
|
||||
raise HTTPException(status_code=409, detail="explicit primary confirmation is required")
|
||||
if body.plan.get("plan_sha256") != body.expected_plan_sha256:
|
||||
raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan")
|
||||
try:
|
||||
result = await apply_repository_identifier_migration(session, body.plan, repo_slug)
|
||||
except IdentifierMigrationError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
from api.routers.workstreams import _invalidate_workplan_index_cache
|
||||
|
||||
_invalidate_workplan_index_cache()
|
||||
return {
|
||||
"schema": "state-hub.identifier-migration-apply.v1",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
"result": asdict(result),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/repositories/{repo_slug}/reverse")
|
||||
async def reverse_identifier_migration(
|
||||
repo_slug: str,
|
||||
body: IdentifierMigrationApplySubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Reverse one sealed repository migration after a failed file phase."""
|
||||
if settings.state_hub_instance_role != "primary":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "identifier migration writes require the primary State Hub",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
},
|
||||
)
|
||||
if not body.primary_confirmed:
|
||||
raise HTTPException(status_code=409, detail="explicit primary confirmation is required")
|
||||
if body.plan.get("plan_sha256") != body.expected_plan_sha256:
|
||||
raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan")
|
||||
try:
|
||||
result = await reverse_repository_identifier_migration(session, body.plan, repo_slug)
|
||||
except IdentifierMigrationError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
from api.routers.workstreams import _invalidate_workplan_index_cache
|
||||
|
||||
_invalidate_workplan_index_cache()
|
||||
return {
|
||||
"schema": "state-hub.identifier-migration-reverse.v1",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
"result": asdict(result),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sealed-projection-repairs",
|
||||
response_model=SealedProjectionRepairReceiptRead,
|
||||
)
|
||||
async def repair_sealed_projection(
|
||||
body: SealedProjectionRepairSubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Restore one exact pre-derivation workplan unit into an absent projection."""
|
||||
try:
|
||||
receipt = await repair_absent_prederivation_projection(
|
||||
session,
|
||||
body.plan,
|
||||
body.repo_slug,
|
||||
body.unit,
|
||||
expected_plan_sha256=body.expected_plan_sha256,
|
||||
source_revision=body.source_revision,
|
||||
source_fingerprint=body.source_fingerprint,
|
||||
source_clean=body.source_clean,
|
||||
source_synchronized=body.source_synchronized,
|
||||
primary_confirmed=body.primary_confirmed,
|
||||
projection_identity=body.projection_identity,
|
||||
)
|
||||
except IdentifierMigrationError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return asdict(receipt)
|
||||
173
api/routers/intake.py
Normal file
173
api/routers/intake.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.intake import (
|
||||
IntakeClose,
|
||||
IntakeCreate,
|
||||
IntakeNoteCreate,
|
||||
IntakeRead,
|
||||
IntakeRoute,
|
||||
IntakeUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/intakes", tags=["intakes"])
|
||||
|
||||
_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted}
|
||||
_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed}
|
||||
|
||||
|
||||
def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None:
|
||||
if intake.status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"Cannot {action} intake in status '{intake.status.value}'; "
|
||||
f"allowed from: {sorted(s.value for s in allowed)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[IntakeRead])
|
||||
async def list_intakes(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
status_: IntakeStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Intake]:
|
||||
q = select(Intake)
|
||||
if topic_id:
|
||||
q = q.where(Intake.topic_id == topic_id)
|
||||
if workplan_id:
|
||||
q = q.where(Intake.workplan_id == workplan_id)
|
||||
if repo_id:
|
||||
q = q.where(Intake.repo_id == repo_id)
|
||||
if status_:
|
||||
q = q.where(Intake.status == status_)
|
||||
q = q.order_by(Intake.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_intake(
|
||||
body: IntakeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = Intake(**body.model_dump())
|
||||
session.add(intake)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.get("/{intake_id}", response_model=IntakeRead)
|
||||
async def get_intake(
|
||||
intake_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
return intake
|
||||
|
||||
|
||||
@router.patch("/{intake_id}", response_model=IntakeRead)
|
||||
async def update_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(intake, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/route", response_model=IntakeRead)
|
||||
async def route_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeRoute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
"""Move an intake into `routed` — eligible for the promotion transition."""
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
_reject_status(intake, _ALLOWED_ROUTE_FROM, "route")
|
||||
|
||||
intake.status = IntakeStatus.routed
|
||||
if body.routed_note:
|
||||
intake.routed_note = body.routed_note
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/close", response_model=IntakeRead)
|
||||
async def close_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeClose,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
"""Close an intake with an outcome. `outcome=promoted` requires
|
||||
`promoted_to` (the canonical id of the record it became) — this is
|
||||
normally called by the promotion transition (CUST-WP-0061-T03), not by
|
||||
hand, but a manual close (declined/absorbed, or a promotion recorded
|
||||
after the fact) is supported directly."""
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
_reject_status(intake, _ALLOWED_CLOSE_FROM, "close")
|
||||
|
||||
intake.status = IntakeStatus.closed
|
||||
intake.outcome = body.outcome
|
||||
intake.closed_at = datetime.now(tz=timezone.utc)
|
||||
if body.promoted_to:
|
||||
intake.promoted_to = body.promoted_to
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=intake.topic_id,
|
||||
workplan_id=intake.workplan_id,
|
||||
event_type="intake_closed",
|
||||
summary=f"Intake closed ({body.outcome.value}): {intake.title}",
|
||||
detail={
|
||||
"intake_id": str(intake.id),
|
||||
"outcome": body.outcome.value,
|
||||
"promoted_to": body.promoted_to,
|
||||
"note": body.note,
|
||||
},
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_intake_note(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeNoteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content)
|
||||
session.add(note)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
|
@ -8,13 +8,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.interface_change import (
|
||||
InterfaceChangeCreate,
|
||||
InterfaceChangePatch,
|
||||
InterfaceChangeRead,
|
||||
)
|
||||
from api.services.repository_aliases import (
|
||||
affected_slug_predicate,
|
||||
canonicalize_repository_slug,
|
||||
resolve_repository_slug,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
|
||||
|
||||
|
|
@ -32,7 +36,12 @@ async def create_interface_change(
|
|||
if body.change_type not in _VALID_CHANGE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
|
||||
|
||||
repo = await _repo_by_slug(body.repo_slug, session)
|
||||
resolution = await resolve_repository_slug(session, body.repo_slug)
|
||||
repo = resolution.repo
|
||||
affected_repo_slugs = [
|
||||
await canonicalize_repository_slug(session, slug)
|
||||
for slug in body.affected_repo_slugs
|
||||
]
|
||||
change = InterfaceChange(
|
||||
repo_id=repo.id,
|
||||
interface_type=body.interface_type,
|
||||
|
|
@ -40,7 +49,7 @@ async def create_interface_change(
|
|||
title=body.title,
|
||||
description=body.description,
|
||||
affected_paths=body.affected_paths,
|
||||
affected_repo_slugs=body.affected_repo_slugs,
|
||||
affected_repo_slugs=affected_repo_slugs,
|
||||
planned_for=body.planned_for,
|
||||
author=body.author,
|
||||
status="draft",
|
||||
|
|
@ -68,7 +77,11 @@ async def list_interface_changes(
|
|||
if change_type:
|
||||
q = q.where(InterfaceChange.change_type == change_type)
|
||||
if affected_repo:
|
||||
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
|
||||
resolution = await resolve_repository_slug(
|
||||
session, affected_repo, required=False
|
||||
)
|
||||
values = resolution.slug_values if resolution else (affected_repo,)
|
||||
q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values))
|
||||
result = await session.execute(q)
|
||||
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
|
||||
|
||||
|
|
@ -94,7 +107,13 @@ async def patch_interface_change(
|
|||
status_code=409,
|
||||
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
|
||||
)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
if payload.get("affected_repo_slugs") is not None:
|
||||
payload["affected_repo_slugs"] = [
|
||||
await canonicalize_repository_slug(session, slug)
|
||||
for slug in payload["affected_repo_slugs"]
|
||||
]
|
||||
for field, value in payload.items():
|
||||
setattr(change, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
|
|
@ -119,12 +138,13 @@ async def publish_interface_change(
|
|||
# Send inbox notifications to agents of affected repos
|
||||
affected = change.affected_repo_slugs or []
|
||||
for slug in affected:
|
||||
target_slug = await canonicalize_repository_slug(session, slug)
|
||||
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
|
||||
if len(change.affected_paths) > 5:
|
||||
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
|
||||
msg = AgentMessage(
|
||||
from_agent=change.repo.slug,
|
||||
to_agent=slug,
|
||||
to_agent=target_slug,
|
||||
subject=f"[{change.change_type.upper()}] {change.title}",
|
||||
body=(
|
||||
f"**Interface change published by `{change.repo.slug}`**\n\n"
|
||||
|
|
@ -174,12 +194,9 @@ async def resolve_interface_change(
|
|||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
async def _repo_by_slug(slug: str, session: AsyncSession):
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
return resolution.repo
|
||||
|
||||
|
||||
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,153 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from hub_core.routers.messages import create_messages_router
|
||||
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||
from api.services.repository_aliases import (
|
||||
canonicalize_repository_slug,
|
||||
resolve_repository_slug,
|
||||
)
|
||||
from hub_core.message_identity import resolve_message_reference
|
||||
from hub_core.models.message_identity_alias import MessageIdentityAlias
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
|
||||
message_id = await resolve_message_reference(
|
||||
session, reference, alias_model=MessageIdentityAlias
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Message reference {reference!r} not found"
|
||||
)
|
||||
message = await session.get(AgentMessage, message_id)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Message reference {reference!r} not found"
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(
|
||||
body: MessageCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
if body.thread_id and await session.get(AgentMessage, body.thread_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
|
||||
payload = body.model_dump()
|
||||
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
|
||||
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
|
||||
message = AgentMessage(**payload)
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MessageRead])
|
||||
async def list_messages(
|
||||
to_agent: str | None = None,
|
||||
from_agent: str | None = None,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
||||
if to_agent:
|
||||
resolution = await resolve_repository_slug(session, to_agent, required=False)
|
||||
values = resolution.slug_values if resolution else (to_agent,)
|
||||
query = query.where(
|
||||
or_(AgentMessage.to_agent.in_(values), AgentMessage.to_agent == "broadcast")
|
||||
)
|
||||
if from_agent:
|
||||
resolution = await resolve_repository_slug(session, from_agent, required=False)
|
||||
values = resolution.slug_values if resolution else (from_agent,)
|
||||
query = query.where(AgentMessage.from_agent.in_(values))
|
||||
if unread_only:
|
||||
query = query.where(AgentMessage.read_at.is_(None))
|
||||
result = await session.execute(
|
||||
query.order_by(AgentMessage.created_at.desc()).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||
async def get_thread(
|
||||
thread_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
resolved = await resolve_message_reference(
|
||||
session, thread_id, alias_model=MessageIdentityAlias
|
||||
)
|
||||
if resolved is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Message reference {thread_id!r} not found"
|
||||
)
|
||||
result = await session.execute(
|
||||
select(AgentMessage)
|
||||
.where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved))
|
||||
.order_by(AgentMessage.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||
async def mark_read(
|
||||
message_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
message = await _get_message(message_id, session)
|
||||
if message.read_at is None:
|
||||
message.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||
async def archive_message(
|
||||
message_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
message = await _get_message(message_id, session)
|
||||
message.archived_at = datetime.now(timezone.utc)
|
||||
if message.read_at is None:
|
||||
message.read_at = message.archived_at
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{message_id}/reply",
|
||||
response_model=MessageRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def reply_to_message(
|
||||
message_id: str,
|
||||
body: MessageReply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
original = await _get_message(message_id, session)
|
||||
if original.read_at is None:
|
||||
original.read_at = datetime.now(timezone.utc)
|
||||
reply = AgentMessage(
|
||||
from_agent=await canonicalize_repository_slug(session, body.from_agent),
|
||||
to_agent=await canonicalize_repository_slug(session, original.from_agent),
|
||||
subject=f"Re: {original.subject}",
|
||||
body=body.body,
|
||||
thread_id=original.thread_id or original.id,
|
||||
)
|
||||
session.add(reply)
|
||||
await session.commit()
|
||||
await session.refresh(reply)
|
||||
return reply
|
||||
|
||||
router = create_messages_router(get_session, message_model=AgentMessage)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
|
|||
13
api/routers/ops_runs.py
Normal file
13
api/routers/ops_runs.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas.ops_run import OpsRunProjection
|
||||
from api.services.ops_run_projection import get_ops_run_projection
|
||||
|
||||
|
||||
router = APIRouter(prefix="/ops-runs", tags=["ops-runs"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=OpsRunProjection)
|
||||
async def get_ops_runs_summary(refresh: bool = False) -> OpsRunProjection:
|
||||
"""Project activity-core queue health; State Hub never claims ops runs."""
|
||||
return await get_ops_run_projection(refresh=refresh)
|
||||
|
|
@ -8,16 +8,14 @@ from api.database import get_session
|
|||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||
from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/repo-goals", tags=["repo-goals"])
|
||||
|
||||
|
||||
async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
|
||||
return repo
|
||||
resolution = await resolve_repository_slug(session, repo_slug)
|
||||
return resolution.repo
|
||||
|
||||
|
||||
@router.get("/", response_model=list[RepoGoalRead])
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ from pathlib import Path
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload
|
||||
from sqlalchemy.orm import joinedload, noload
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
|
|
@ -28,6 +29,7 @@ from api.models.domain import Domain
|
|||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.repository_rename import RepositorySlug
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
from api.models.task import Task
|
||||
from api.models.workplan import Workplan
|
||||
|
|
@ -48,6 +50,18 @@ from api.schemas.managed_repo import (
|
|||
classification_fields_set,
|
||||
validate_repo_classification_fields,
|
||||
)
|
||||
from api.services.sbom_nexus import SBOMNexusError
|
||||
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
|
||||
from api.services.sbom_nexus import reads_from_nexus
|
||||
from api.services.repository_identity import stage_initial_repository_identity
|
||||
from api.services.repository_aliases import (
|
||||
RepositorySlugResolution,
|
||||
affected_slug_predicate,
|
||||
repository_resolutions_for_repositories,
|
||||
repository_resolution_for_id,
|
||||
resolve_repository_slug,
|
||||
stale_external_references,
|
||||
)
|
||||
from hub_core.routers.repos import create_repos_router
|
||||
|
||||
router = APIRouter(prefix="/repos", tags=["repos"])
|
||||
|
|
@ -108,22 +122,25 @@ async def list_repos(
|
|||
capability_tag: str | None = None,
|
||||
business_stake: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ManagedRepo]:
|
||||
) -> list[RepoRead]:
|
||||
"""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))
|
||||
.options(
|
||||
noload("*"),
|
||||
joinedload(ManagedRepo.domain).noload("*"),
|
||||
)
|
||||
.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:
|
||||
domain_result = await session.execute(select(Domain.id).where(Domain.slug == domain))
|
||||
domain_id = domain_result.scalar_one_or_none()
|
||||
if domain_id is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
|
||||
q = q.where(
|
||||
or_(
|
||||
ManagedRepo.domain_id == domain_obj.id,
|
||||
ManagedRepo.domain_id == domain_id,
|
||||
ManagedRepo.secondary_domains.contains([domain]),
|
||||
)
|
||||
)
|
||||
|
|
@ -134,20 +151,25 @@ async def list_repos(
|
|||
if business_stake:
|
||||
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
repositories = list(result.scalars().all())
|
||||
resolutions = await repository_resolutions_for_repositories(session, repositories)
|
||||
return await _project_repo_reads(session, repositories, resolutions=resolutions)
|
||||
|
||||
|
||||
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register_repo(
|
||||
body: RepoCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
) -> RepoRead:
|
||||
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():
|
||||
claimed_slug = await session.execute(
|
||||
select(RepositorySlug).where(RepositorySlug.slug == body.slug)
|
||||
)
|
||||
if existing.scalar_one_or_none() or claimed_slug.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||
|
||||
payload = body.model_dump()
|
||||
|
|
@ -176,10 +198,19 @@ async def register_repo(
|
|||
standard_version=body.standard_version,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.flush()
|
||||
stage_initial_repository_identity(session, repo)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Repo slug '{body.slug}' is already claimed",
|
||||
) from exc
|
||||
await session.refresh(repo)
|
||||
await _publish_repo_registered(repo, body, domain_obj)
|
||||
return repo
|
||||
return (await _project_repo_reads(session, [repo]))[0]
|
||||
|
||||
|
||||
@router.post("/onboard", response_model=RepoOnboardResult)
|
||||
|
|
@ -330,6 +361,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS
|
|||
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name)
|
||||
)
|
||||
repos = list(repos_result.scalars().all())
|
||||
sbom_projections = await _sbom_projection_map()
|
||||
repo_ids = [r.id for r in repos]
|
||||
id_to_slug = {r.id: r.slug for r in repos}
|
||||
|
||||
|
|
@ -385,7 +417,7 @@ async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoIS
|
|||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
"last_sbom_at": _projected_last_sbom_at(repo, sbom_projections),
|
||||
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
||||
}
|
||||
fp = compute_fingerprint(
|
||||
|
|
@ -464,7 +496,9 @@ async def get_repo_doi(
|
|||
|
||||
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
repo = resolution.repo
|
||||
sbom_projections = await _sbom_projection_map()
|
||||
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -484,7 +518,7 @@ async def get_repo_doi(
|
|||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
"last_sbom_at": _projected_last_sbom_at(repo, sbom_projections),
|
||||
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
||||
}
|
||||
fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None,
|
||||
|
|
@ -497,7 +531,7 @@ async def get_repo_doi(
|
|||
|
||||
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
|
||||
return DoIReport(
|
||||
repo_slug=slug,
|
||||
repo_slug=repo.slug,
|
||||
tier=cached.tier,
|
||||
core_pass=cached.core_pass,
|
||||
standard_pass=cached.standard_pass,
|
||||
|
|
@ -536,11 +570,11 @@ async def get_repo_doi(
|
|||
async def get_repo_by_id(
|
||||
repo_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
) -> RepoRead:
|
||||
repo = await session.get(ManagedRepo, repo_id)
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
|
||||
return repo
|
||||
return (await _project_repo_reads(session, [repo]))[0]
|
||||
|
||||
|
||||
@router.get("/scope-health", response_model=list[RepoScopeHealth])
|
||||
|
|
@ -595,14 +629,32 @@ async def update_repo_with_classification(
|
|||
slug: str,
|
||||
body: RepoUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
) -> RepoRead:
|
||||
"""Patch repo metadata including classification spine fields."""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
repo = resolution.repo
|
||||
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()
|
||||
requested_domain_slug = payload.pop("domain_slug", None)
|
||||
if requested_domain_slug is not None:
|
||||
domain_result = await session.execute(
|
||||
select(Domain).where(Domain.slug == requested_domain_slug)
|
||||
)
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
if domain_obj is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Domain '{requested_domain_slug}' not found",
|
||||
)
|
||||
else:
|
||||
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):
|
||||
classification_requested = classification_fields_set(payload)
|
||||
if classification_requested or (
|
||||
requested_domain_slug is not None and repo.category is not None
|
||||
):
|
||||
merged = {
|
||||
"category": payload.get("category", repo.category),
|
||||
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
|
||||
|
|
@ -615,31 +667,67 @@ async def update_repo_with_classification(
|
|||
fields=merged,
|
||||
require_complete=True,
|
||||
)
|
||||
if requested_domain_slug is not None:
|
||||
repo.domain_id = domain_obj.id
|
||||
for field, value in payload.items():
|
||||
setattr(repo, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=RepoRead)
|
||||
async def get_repo_with_sbom_projection(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoRead:
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
return (
|
||||
await _project_repo_reads(
|
||||
session,
|
||||
[resolution.repo],
|
||||
requested=resolution,
|
||||
include_stale_external=True,
|
||||
)
|
||||
)[0]
|
||||
|
||||
|
||||
router.include_router(
|
||||
_core_repo_router(
|
||||
include_collection_routes=False,
|
||||
include_lookup_routes=False,
|
||||
include_slug_routes=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/paths", response_model=RepoRead)
|
||||
async def register_repo_path(
|
||||
slug: str,
|
||||
body: RepoPathRegister,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoRead:
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
repo = resolution.repo
|
||||
host_paths = dict(repo.host_paths or {})
|
||||
host_paths[body.host] = body.path
|
||||
repo.host_paths = host_paths
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
|
||||
|
||||
|
||||
@router.patch("/{slug}/archive", response_model=RepoRead)
|
||||
async def archive_repo(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
) -> RepoRead:
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
repo = resolution.repo
|
||||
repo.status = "archived"
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
return (await _project_repo_reads(session, [repo], requested=resolution))[0]
|
||||
|
||||
|
||||
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
|
||||
|
|
@ -653,7 +741,8 @@ async def get_repo_dispatch(
|
|||
call it at session start to discover what work is pending without needing to
|
||||
read state-hub summary or scan workplan files manually.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
repo = resolution.repo
|
||||
|
||||
# Active goal
|
||||
goal_result = await session.execute(
|
||||
|
|
@ -717,7 +806,9 @@ async def get_repo_dispatch(
|
|||
ic_result = await session.execute(
|
||||
select(InterfaceChange).where(
|
||||
InterfaceChange.status == "published",
|
||||
InterfaceChange.affected_repo_slugs.contains([slug]),
|
||||
affected_slug_predicate(
|
||||
InterfaceChange.affected_repo_slugs, resolution.slug_values
|
||||
),
|
||||
).order_by(InterfaceChange.published_at.desc())
|
||||
)
|
||||
pending_changes = [
|
||||
|
|
@ -746,7 +837,12 @@ async def get_repo_dispatch(
|
|||
)
|
||||
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
repo_slug=resolution.canonical_slug,
|
||||
requested_slug=resolution.requested_slug,
|
||||
canonical_slug=resolution.canonical_slug,
|
||||
slug_status=resolution.slug_status,
|
||||
aliases=list(resolution.aliases),
|
||||
stale_external_references=await stale_external_references(session, resolution),
|
||||
active_goal=active_goal,
|
||||
active_workplans=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
|
|
@ -771,7 +867,8 @@ async def sync_repo_consistency(
|
|||
Returns the raw JSON output from consistency_check.py.
|
||||
Query param ?fix=false to run check-only without writing.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
repo = resolution.repo
|
||||
|
||||
hostname = socket.gethostname()
|
||||
host_paths = repo.host_paths or {}
|
||||
|
|
@ -780,13 +877,13 @@ async def sync_repo_consistency(
|
|||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
f"No accessible path for repo '{slug}' on host '{hostname}'. "
|
||||
f"Register with: POST /repos/{slug}/paths/"
|
||||
f"No accessible path for repo '{repo.slug}' on host '{hostname}'. "
|
||||
f"Register with: POST /repos/{repo.slug}/paths/"
|
||||
),
|
||||
)
|
||||
|
||||
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
|
||||
cmd = [sys.executable, str(script), "--repo", slug, "--json",
|
||||
cmd = [sys.executable, str(script), "--repo", repo.slug, "--json",
|
||||
"--api-base", settings.api_base]
|
||||
if fix:
|
||||
cmd.append("--fix")
|
||||
|
|
@ -805,11 +902,8 @@ async def sync_repo_consistency(
|
|||
|
||||
|
||||
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
return resolution.repo
|
||||
|
||||
|
||||
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
|
||||
|
|
@ -822,3 +916,77 @@ def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
|
|||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
async def _sbom_projection_map() -> dict[str, datetime | None]:
|
||||
if not reads_from_nexus():
|
||||
return {}
|
||||
try:
|
||||
repositories = await get_sbom_nexus_json("/repositories/")
|
||||
except SBOMNexusError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
return {
|
||||
repository["slug"]: (
|
||||
datetime.fromisoformat(repository["last_attempt_at"].replace("Z", "+00:00"))
|
||||
if repository.get("last_attempt_at")
|
||||
else None
|
||||
)
|
||||
for repository in repositories
|
||||
}
|
||||
|
||||
|
||||
def _projected_last_sbom_at(
|
||||
repo: ManagedRepo,
|
||||
projections: dict[str, datetime | None],
|
||||
) -> str | None:
|
||||
if repo.slug in projections:
|
||||
projected = projections[repo.slug]
|
||||
return projected.isoformat() if projected else None
|
||||
return str(repo.last_sbom_at) if repo.last_sbom_at else None
|
||||
|
||||
|
||||
async def _project_repo_reads(
|
||||
session: AsyncSession,
|
||||
repositories: list[ManagedRepo],
|
||||
*,
|
||||
requested: RepositorySlugResolution | None = None,
|
||||
resolutions: dict[uuid.UUID, RepositorySlugResolution] | None = None,
|
||||
include_stale_external: bool = False,
|
||||
) -> list[RepoRead]:
|
||||
projections = await _sbom_projection_map()
|
||||
result: list[RepoRead] = []
|
||||
for repository in repositories:
|
||||
resolution = (
|
||||
requested
|
||||
if requested is not None and requested.repo.id == repository.id
|
||||
else (
|
||||
resolutions[repository.id]
|
||||
if resolutions is not None
|
||||
else await repository_resolution_for_id(session, repository)
|
||||
)
|
||||
)
|
||||
read = RepoRead.model_validate(repository)
|
||||
read = read.model_copy(
|
||||
update={
|
||||
"requested_slug": resolution.requested_slug,
|
||||
"canonical_slug": resolution.canonical_slug,
|
||||
"slug_status": resolution.slug_status,
|
||||
"aliases": list(resolution.aliases),
|
||||
"stale_external_references": (
|
||||
await stale_external_references(session, resolution)
|
||||
if include_stale_external
|
||||
else []
|
||||
),
|
||||
}
|
||||
)
|
||||
if repository.slug in projections:
|
||||
read = read.model_copy(
|
||||
update={
|
||||
"last_sbom_at": projections[repository.slug],
|
||||
"sbom_source": "sbom-nexus"
|
||||
if projections[repository.slug]
|
||||
else None,
|
||||
}
|
||||
)
|
||||
result.append(read)
|
||||
return result
|
||||
|
|
|
|||
359
api/routers/repository_renames.py
Normal file
359
api/routers/repository_renames.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.repository_rename import RepositoryRenameOperation
|
||||
from api.schemas.repository_rename import (
|
||||
ForgeIdentityVerifyRequest,
|
||||
RepositoryRenameOperationCreate,
|
||||
RepositoryRenameOperationRead,
|
||||
RepositoryRenamePhaseApply,
|
||||
RepositoryRenamePreflightRead,
|
||||
RepositoryRenamePreflightRequest,
|
||||
RepositoryRenameRollbackPreflightRead,
|
||||
RepositoryRenameRollbackRequest,
|
||||
RepositoryRenameVerificationRead,
|
||||
)
|
||||
from api.services.forge_repository import (
|
||||
ForgeRepositoryGateway,
|
||||
get_forge_repository_gateway,
|
||||
)
|
||||
from api.services.repository_rename import (
|
||||
RenameLifecycleError,
|
||||
apply_phase,
|
||||
apply_rollback,
|
||||
build_preflight,
|
||||
create_operation,
|
||||
list_operations,
|
||||
load_operation,
|
||||
load_operation_by_id,
|
||||
rollback_preflight,
|
||||
verify_forge_identity,
|
||||
verify_operation,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
|
||||
operation_router = APIRouter(
|
||||
prefix="/repository-renames", tags=["repository-renames"]
|
||||
)
|
||||
|
||||
|
||||
def repository_rename_events(
|
||||
operation: RepositoryRenameOperation,
|
||||
*,
|
||||
outcome: str,
|
||||
no_op: bool = False,
|
||||
verification: dict[str, Any] | None = None,
|
||||
) -> list[tuple[str, EventEnvelope]]:
|
||||
"""Build credential-free lifecycle events from the durable journal."""
|
||||
telemetry = (operation.evidence or {}).get("telemetry") or {}
|
||||
phase_durations = telemetry.get("phase_durations_ms") or {}
|
||||
attributes = {
|
||||
"operation_id": str(operation.id),
|
||||
"repo_id": str(operation.repo_id),
|
||||
"forge_repository_id": operation.expected_forge_repository_id,
|
||||
"old_slug": operation.old_slug,
|
||||
"new_slug": operation.new_slug,
|
||||
"phase": operation.phase,
|
||||
"actor": operation.actor,
|
||||
"expected_source_commit": operation.expected_source_commit,
|
||||
"outcome": outcome,
|
||||
"no_op": no_op,
|
||||
"phase_duration_ms": int(phase_durations.get(operation.phase) or 0),
|
||||
"retries": int(telemetry.get("retries") or 0),
|
||||
"failures": int(telemetry.get("failures") or 0),
|
||||
"rollback_attempts": int(telemetry.get("rollback_attempts") or 0),
|
||||
"rollback_outcome": telemetry.get("rollback_outcome"),
|
||||
"verification_outcome": (
|
||||
"passed" if verification and verification.get("ok") else
|
||||
"failed" if verification else
|
||||
telemetry.get("verification_outcome", "pending")
|
||||
),
|
||||
"error_code": operation.error_code if outcome == "failed" else None,
|
||||
"evidence_ref": f"repository-renames/operations/{operation.id}",
|
||||
}
|
||||
subject = (
|
||||
"org.statehub.repo.rename.failed"
|
||||
if outcome == "failed"
|
||||
else "org.statehub.repo.rename.verified"
|
||||
if verification is not None
|
||||
else "org.statehub.repo.rename.rolled_back"
|
||||
if operation.phase == "rolled-back"
|
||||
else "org.statehub.repo.rename.phase"
|
||||
)
|
||||
events = [(subject, EventEnvelope.new(subject, attributes=attributes))]
|
||||
if operation.phase == "completed" and outcome == "succeeded":
|
||||
renamed_subject = "org.statehub.repo.renamed"
|
||||
events.append(
|
||||
(renamed_subject, EventEnvelope.new(renamed_subject, attributes=attributes))
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _publish_rename_events(
|
||||
operation: RepositoryRenameOperation,
|
||||
*,
|
||||
outcome: str,
|
||||
no_op: bool = False,
|
||||
verification: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
for subject, envelope in repository_rename_events(
|
||||
operation, outcome=outcome, no_op=no_op, verification=verification
|
||||
):
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
|
||||
def _raise(exc: RenameLifecycleError) -> None:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail=jsonable_encoder(
|
||||
{"code": exc.code, "message": str(exc), "details": exc.details}
|
||||
),
|
||||
) from exc
|
||||
|
||||
|
||||
def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"id": operation.id,
|
||||
"repo_id": operation.repo_id,
|
||||
"phase": operation.phase,
|
||||
"old_slug": operation.old_slug,
|
||||
"new_slug": operation.new_slug,
|
||||
"expected_forge_repository_id": operation.expected_forge_repository_id,
|
||||
"expected_source_commit": operation.expected_source_commit,
|
||||
"expected_default_branch": operation.expected_default_branch,
|
||||
"actor": operation.actor,
|
||||
"phase_changed_at": operation.phase_changed_at,
|
||||
"preflighted_at": operation.preflighted_at,
|
||||
"preflight_expires_at": operation.preflight_expires_at,
|
||||
"completed_at": operation.completed_at,
|
||||
"rolled_back_at": operation.rolled_back_at,
|
||||
"evidence": operation.evidence,
|
||||
"error_code": operation.error_code,
|
||||
"error_message": operation.error_message,
|
||||
"error_details": operation.error_details,
|
||||
"error_at": operation.error_at,
|
||||
"no_op": no_op,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forge-identity/verify")
|
||||
async def verify_repository_forge_identity(
|
||||
repo_id: uuid.UUID,
|
||||
body: ForgeIdentityVerifyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
identity = await verify_forge_identity(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return {
|
||||
"id": identity.id,
|
||||
"repo_id": identity.repo_id,
|
||||
"provider": identity.provider,
|
||||
"forge_instance": identity.forge_instance,
|
||||
"forge_owner": identity.forge_owner,
|
||||
"forge_repository_id": identity.forge_repository_id,
|
||||
"verification_state": identity.verification_state,
|
||||
"verified_at": identity.verified_at,
|
||||
"verified_by": identity.verified_by,
|
||||
"verification_evidence": identity.verification_evidence,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead)
|
||||
async def repository_rename_preflight(
|
||||
repo_id: uuid.UUID,
|
||||
body: RepositoryRenamePreflightRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await build_preflight(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations",
|
||||
response_model=RepositoryRenameOperationRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
body: RepositoryRenameOperationCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, no_op = await create_operation(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
|
||||
|
||||
@operation_router.get(
|
||||
"/operations/{operation_id}", response_model=RepositoryRenameOperationRead
|
||||
)
|
||||
async def get_repository_rename_operation_by_id(
|
||||
operation_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve an operation journal without requiring its repository UUID."""
|
||||
try:
|
||||
operation = await load_operation_by_id(session, operation_id)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rename/operations", response_model=list[RepositoryRenameOperationRead]
|
||||
)
|
||||
async def list_repository_rename_operations(
|
||||
repo_id: uuid.UUID,
|
||||
active_only: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
operations = await list_operations(session, repo_id, active_only=active_only)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return [_operation_read(operation) for operation in operations]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead
|
||||
)
|
||||
async def get_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation = await load_operation(session, repo_id, operation_id)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations/{operation_id}/phases/{phase}",
|
||||
response_model=RepositoryRenameOperationRead,
|
||||
)
|
||||
async def apply_repository_rename_phase(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
phase: str,
|
||||
body: RepositoryRenamePhaseApply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, no_op = await apply_phase(
|
||||
session, gateway, repo_id, operation_id, phase, body
|
||||
)
|
||||
except RenameLifecycleError as exc:
|
||||
failed = await session.get(RepositoryRenameOperation, operation_id)
|
||||
if failed is not None:
|
||||
_publish_rename_events(failed, outcome="failed")
|
||||
_raise(exc)
|
||||
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rename/operations/{operation_id}/verify",
|
||||
response_model=RepositoryRenameVerificationRead,
|
||||
)
|
||||
async def verify_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation = await load_operation(session, repo_id, operation_id)
|
||||
verification = await verify_operation(session, gateway, operation)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
_publish_rename_events(
|
||||
operation,
|
||||
outcome="succeeded" if verification["ok"] else "failed",
|
||||
verification=verification,
|
||||
)
|
||||
return verification
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations/{operation_id}/rollback-preflight",
|
||||
response_model=RepositoryRenameRollbackPreflightRead,
|
||||
)
|
||||
async def preflight_repository_rename_rollback(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
body: RepositoryRenameRollbackRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, report = await rollback_preflight(
|
||||
session,
|
||||
gateway,
|
||||
repo_id,
|
||||
operation_id,
|
||||
expected_phase=body.expected_phase,
|
||||
confirmation=body.confirmation,
|
||||
)
|
||||
except RenameLifecycleError as exc:
|
||||
failed = await session.get(RepositoryRenameOperation, operation_id)
|
||||
if failed is not None:
|
||||
_publish_rename_events(failed, outcome="failed")
|
||||
_raise(exc)
|
||||
_publish_rename_events(operation, outcome="succeeded")
|
||||
return {
|
||||
"operation_id": operation.id,
|
||||
"repo_id": operation.repo_id,
|
||||
**report,
|
||||
"operation": _operation_read(operation),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rename/operations/{operation_id}/rollback",
|
||||
response_model=RepositoryRenameOperationRead,
|
||||
)
|
||||
async def rollback_repository_rename_operation(
|
||||
repo_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
body: RepositoryRenameRollbackRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation, no_op = await apply_rollback(
|
||||
session,
|
||||
gateway,
|
||||
repo_id,
|
||||
operation_id,
|
||||
expected_phase=body.expected_phase,
|
||||
confirmation=body.confirmation,
|
||||
)
|
||||
except RenameLifecycleError as exc:
|
||||
failed = await session.get(RepositoryRenameOperation, operation_id)
|
||||
if failed is not None:
|
||||
_publish_rename_events(failed, outcome="failed")
|
||||
_raise(exc)
|
||||
_publish_rename_events(operation, outcome="succeeded", no_op=no_op)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
190
api/routers/review_contracts.py
Normal file
190
api/routers/review_contracts.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||
from api.schemas.review_contract import (
|
||||
ReviewAggregateRead,
|
||||
ReviewContractProject,
|
||||
ReviewContractRead,
|
||||
ReviewReceiptRead,
|
||||
ReviewReceiptSubmit,
|
||||
)
|
||||
from api.services.review_contracts import (
|
||||
active_contract,
|
||||
aggregate,
|
||||
digest_document,
|
||||
normalize_contract,
|
||||
utc_now,
|
||||
validate_receipt,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/review-contracts", tags=["review-contracts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projections",
|
||||
response_model=ReviewContractRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def project_contract(
|
||||
body: ReviewContractProject,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewContract:
|
||||
document, contract_digest = normalize_contract(body.contract)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ReviewContract).where(
|
||||
ReviewContract.contract_digest == contract_digest
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
if (
|
||||
existing.source_repo != body.source.repo
|
||||
or existing.source_path != body.source.path
|
||||
or existing.source_revision != body.source.revision
|
||||
):
|
||||
raise HTTPException(
|
||||
409, "contract digest is already projected from a different source"
|
||||
)
|
||||
return existing
|
||||
contract_key = document["contract_key"]
|
||||
await session.execute(
|
||||
update(ReviewContract)
|
||||
.where(
|
||||
ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True)
|
||||
)
|
||||
.values(active=False)
|
||||
)
|
||||
contract = ReviewContract(
|
||||
contract_key=contract_key,
|
||||
schema_version="review-contract/v1",
|
||||
contract_digest=contract_digest,
|
||||
source_repo=body.source.repo,
|
||||
source_path=body.source.path,
|
||||
source_revision=body.source.revision,
|
||||
document=document,
|
||||
active=True,
|
||||
required_for_decision=body.required_for_decision,
|
||||
decision_id=body.decision_id,
|
||||
workplan_id=body.workplan_id,
|
||||
task_id=body.task_id,
|
||||
projected_at=utc_now(),
|
||||
)
|
||||
session.add(contract)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
409, "source revision is already projected with different content"
|
||||
) from exc
|
||||
await session.refresh(contract)
|
||||
return contract
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ReviewContractRead])
|
||||
async def list_contracts(
|
||||
contract_key: str | None = Query(None),
|
||||
active: bool | None = Query(None),
|
||||
decision_id: uuid.UUID | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ReviewContract]:
|
||||
query = select(ReviewContract).order_by(ReviewContract.projected_at.desc())
|
||||
if contract_key:
|
||||
query = query.where(ReviewContract.contract_key == contract_key)
|
||||
if active is not None:
|
||||
query = query.where(ReviewContract.active == active)
|
||||
if decision_id:
|
||||
query = query.where(ReviewContract.decision_id == decision_id)
|
||||
return list((await session.execute(query)).scalars())
|
||||
|
||||
|
||||
@router.get("/{contract_key}", response_model=ReviewContractRead)
|
||||
async def get_active_contract(
|
||||
contract_key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewContract:
|
||||
return await active_contract(session, contract_key)
|
||||
|
||||
|
||||
@router.get("/{contract_key}/aggregate", response_model=ReviewAggregateRead)
|
||||
async def get_aggregate(
|
||||
contract_key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewAggregateRead:
|
||||
return await aggregate(session, await active_contract(session, contract_key))
|
||||
|
||||
|
||||
@router.get("/{contract_key}/receipts", response_model=list[ReviewReceiptRead])
|
||||
async def list_receipts(
|
||||
contract_key: str,
|
||||
include_stale: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ReviewReceipt]:
|
||||
contract = await active_contract(session, contract_key)
|
||||
query = (
|
||||
select(ReviewReceipt)
|
||||
.join(ReviewContract)
|
||||
.where(
|
||||
ReviewContract.contract_key == contract_key
|
||||
if include_stale
|
||||
else ReviewReceipt.contract_id == contract.id
|
||||
)
|
||||
.order_by(ReviewReceipt.submitted_at)
|
||||
)
|
||||
return list((await session.execute(query)).scalars())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{contract_key}/receipts",
|
||||
response_model=ReviewReceiptRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def submit_receipt(
|
||||
contract_key: str,
|
||||
body: ReviewReceiptSubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewReceipt:
|
||||
contract = await active_contract(session, contract_key)
|
||||
document = validate_receipt(contract, body)
|
||||
receipt_digest = digest_document(document)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ReviewReceipt).where(ReviewReceipt.receipt_digest == receipt_digest)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
receipt = ReviewReceipt(
|
||||
contract_id=contract.id,
|
||||
owner_id=body.owner_id,
|
||||
actor=body.actor,
|
||||
disposition=body.disposition,
|
||||
contract_digest=body.contract_digest,
|
||||
receipt_digest=receipt_digest,
|
||||
artifact_hashes=body.artifact_hashes,
|
||||
assertion_ids=body.assertion_ids,
|
||||
checks=body.checks,
|
||||
note=body.note,
|
||||
source_repo=body.source.repo,
|
||||
source_path=body.source.path,
|
||||
source_revision=body.source.revision,
|
||||
submitted_at=utc_now(),
|
||||
document=document,
|
||||
)
|
||||
session.add(receipt)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
409, "receipt source revision already contains different evidence"
|
||||
) from exc
|
||||
await session.refresh(receipt)
|
||||
return receipt
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -18,8 +19,18 @@ from api.schemas.sbom import (
|
|||
SBOMSnapshotDetail,
|
||||
SBOMSnapshotRead,
|
||||
)
|
||||
from api.services.legacy_meter import identity_from_request, record_legacy_usage
|
||||
from api.services.sbom_nexus import (
|
||||
SBOMNexusError,
|
||||
get_json,
|
||||
post_json,
|
||||
reads_from_nexus,
|
||||
writes_to_nexus,
|
||||
)
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/sbom", tags=["sbom"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
|
||||
|
||||
|
|
@ -54,10 +65,37 @@ def _latest_snapshot_ids_subquery():
|
|||
@router.post("/ingest/")
|
||||
async def ingest_sbom(
|
||||
body: SBOMIngest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
|
||||
repo = await _get_repo_by_slug(body.repo_slug, session)
|
||||
resolution = await resolve_repository_slug(session, body.repo_slug)
|
||||
repo = resolution.repo
|
||||
if writes_to_nexus():
|
||||
nexus_body = body.model_dump(mode="json")
|
||||
nexus_body["repo_slug"] = resolution.canonical_slug
|
||||
payload = await _nexus_post("/sbom/ingest/", body=nexus_body)
|
||||
try:
|
||||
snapshot_at = datetime.fromisoformat(
|
||||
payload["snapshot_at"].replace("Z", "+00:00")
|
||||
)
|
||||
result = {
|
||||
"repo_slug": payload["repo_slug"],
|
||||
"snapshot_id": payload["snapshot_id"],
|
||||
"ingested": payload["ingested"],
|
||||
"snapshot_at": payload["snapshot_at"],
|
||||
}
|
||||
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="SBOM Nexus returned an invalid ingest response",
|
||||
) from exc
|
||||
repo.last_sbom_at = snapshot_at
|
||||
repo.sbom_source = "sbom-nexus"
|
||||
await session.commit()
|
||||
await _meter_compat(session, request, "POST", "/sbom/ingest/")
|
||||
return result
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
snap = SBOMSnapshot(
|
||||
|
|
@ -90,8 +128,9 @@ async def ingest_sbom(
|
|||
repo.sbom_source = "manual"
|
||||
|
||||
await session.commit()
|
||||
await _meter_compat(session, request, "POST", "/sbom/ingest/")
|
||||
return {
|
||||
"repo_slug": body.repo_slug,
|
||||
"repo_slug": resolution.canonical_slug,
|
||||
"snapshot_id": str(snap.id),
|
||||
"ingested": len(body.entries),
|
||||
"snapshot_at": now.isoformat(),
|
||||
|
|
@ -100,10 +139,23 @@ async def ingest_sbom(
|
|||
|
||||
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
|
||||
async def list_snapshots(
|
||||
request: Request,
|
||||
repo_slug: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SBOMSnapshotRead]:
|
||||
"""List SBOM snapshots, newest first. Optionally filter by repo."""
|
||||
await _meter_compat(session, request, "GET", "/sbom/snapshots/")
|
||||
if reads_from_nexus():
|
||||
if repo_slug:
|
||||
repo_slug = (
|
||||
await resolve_repository_slug(session, repo_slug)
|
||||
).canonical_slug
|
||||
payload = await _nexus_get(
|
||||
"/sbom/snapshots/",
|
||||
params={"repo_slug": repo_slug} if repo_slug else None,
|
||||
)
|
||||
return await _translate_snapshots(payload, session)
|
||||
|
||||
q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
|
||||
if repo_slug:
|
||||
repo = await _get_repo_by_slug(repo_slug, session)
|
||||
|
|
@ -115,9 +167,21 @@ async def list_snapshots(
|
|||
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
|
||||
async def get_snapshot(
|
||||
snapshot_id: uuid.UUID,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SBOMSnapshotDetail:
|
||||
"""Get a snapshot with its full entry list."""
|
||||
await _meter_compat(session, request, "GET", "/sbom/snapshots/{snapshot_id}")
|
||||
if reads_from_nexus():
|
||||
payload = await _nexus_get(f"/sbom/snapshots/{snapshot_id}")
|
||||
repo_ids = await _local_repo_ids([payload], session)
|
||||
repo_id = repo_ids[payload["repo_slug"]]
|
||||
translated = _translate_snapshot(payload, repo_id)
|
||||
translated["entries"] = [
|
||||
_translate_entry(entry, repo_id) for entry in payload.get("entries", [])
|
||||
]
|
||||
return SBOMSnapshotDetail.model_validate(translated)
|
||||
|
||||
snap = await session.get(SBOMSnapshot, snapshot_id)
|
||||
if snap is None:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
|
||||
|
|
@ -140,6 +204,7 @@ async def get_snapshot(
|
|||
|
||||
@router.get("/")
|
||||
async def list_sbom_entries(
|
||||
request: Request,
|
||||
repo_slug: str | None = Query(None),
|
||||
ecosystem: Ecosystem | None = Query(None),
|
||||
license_spdx: str | None = Query(None),
|
||||
|
|
@ -148,6 +213,30 @@ async def list_sbom_entries(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SBOMEntryRead]:
|
||||
"""Return entries from the latest snapshot per repo (default) or filter by repo."""
|
||||
await _meter_compat(session, request, "GET", "/sbom/")
|
||||
if reads_from_nexus():
|
||||
if repo_slug:
|
||||
repo_slug = (
|
||||
await resolve_repository_slug(session, repo_slug)
|
||||
).canonical_slug
|
||||
params = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"repo_slug": repo_slug,
|
||||
"ecosystem": ecosystem.value if ecosystem is not None else None,
|
||||
"license_spdx": license_spdx,
|
||||
"is_direct": is_direct,
|
||||
"is_dev": is_dev,
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
payload = await _nexus_get("/sbom/", params=params)
|
||||
repo_ids = await _local_repo_ids(payload, session)
|
||||
return [
|
||||
SBOMEntryRead.model_validate(_translate_entry(entry, repo_ids[entry["repo_slug"]]))
|
||||
for entry in payload
|
||||
]
|
||||
|
||||
if repo_slug:
|
||||
repo = await _get_repo_by_slug(repo_slug, session)
|
||||
latest_snap_id_sq = (
|
||||
|
|
@ -177,9 +266,15 @@ async def list_sbom_entries(
|
|||
|
||||
@router.get("/report/licences/", response_model=LicenceReport)
|
||||
async def licence_report(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LicenceReport:
|
||||
"""Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
|
||||
await _meter_compat(session, request, "GET", "/sbom/report/licences/")
|
||||
if reads_from_nexus():
|
||||
payload = await _nexus_get("/sbom/report/licences/")
|
||||
return LicenceReport.model_validate(payload)
|
||||
|
||||
latest_ids_sq = _latest_snapshot_ids_subquery()
|
||||
rows = await session.execute(
|
||||
select(SBOMEntry, ManagedRepo.slug)
|
||||
|
|
@ -212,10 +307,20 @@ async def licence_report(
|
|||
@router.get("/{repo_slug}", response_model=SBOMRepoView)
|
||||
async def get_repo_sbom(
|
||||
repo_slug: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SBOMRepoView:
|
||||
"""Return the latest snapshot entries for a specific repo."""
|
||||
repo = await _get_repo_by_slug(repo_slug, session)
|
||||
resolution = await resolve_repository_slug(session, repo_slug)
|
||||
repo = resolution.repo
|
||||
await _meter_compat(session, request, "GET", "/sbom/{repo_slug}")
|
||||
if reads_from_nexus():
|
||||
payload = await _nexus_get(f"/sbom/{resolution.canonical_slug}")
|
||||
payload["entries"] = [
|
||||
_translate_entry(entry, repo.id) for entry in payload.get("entries", [])
|
||||
]
|
||||
return SBOMRepoView.model_validate(payload)
|
||||
|
||||
latest_snap_id_sq = (
|
||||
select(SBOMSnapshot.id)
|
||||
.where(SBOMSnapshot.repo_id == repo.id)
|
||||
|
|
@ -230,7 +335,7 @@ async def get_repo_sbom(
|
|||
)
|
||||
entries = list(rows.scalars().all())
|
||||
return SBOMRepoView(
|
||||
repo_slug=repo_slug,
|
||||
repo_slug=resolution.canonical_slug,
|
||||
last_sbom_at=repo.last_sbom_at,
|
||||
entry_count=len(entries),
|
||||
entries=[SBOMEntryRead.model_validate(e) for e in entries],
|
||||
|
|
@ -238,8 +343,86 @@ async def get_repo_sbom(
|
|||
|
||||
|
||||
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
return resolution.repo
|
||||
|
||||
|
||||
async def _nexus_get(path: str, *, params: dict | None = None):
|
||||
try:
|
||||
return await get_json(path, params=params)
|
||||
except SBOMNexusError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
async def _nexus_post(path: str, *, body: dict):
|
||||
try:
|
||||
return await post_json(path, body=body)
|
||||
except SBOMNexusError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
|
||||
|
||||
async def _local_repo_ids(items: list[dict], session: AsyncSession) -> dict[str, uuid.UUID]:
|
||||
slugs = {item.get("repo_slug") for item in items}
|
||||
if None in slugs:
|
||||
raise HTTPException(status_code=502, detail="SBOM Nexus response omitted repo_slug")
|
||||
repo_ids: dict[str, uuid.UUID] = {}
|
||||
for slug in slugs:
|
||||
resolution = await resolve_repository_slug(session, slug, required=False)
|
||||
if resolution is not None:
|
||||
repo_ids[slug] = resolution.repo.id
|
||||
missing = sorted(slugs - repo_ids.keys())
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"SBOM Nexus returned repositories absent from State Hub: {', '.join(missing)}",
|
||||
)
|
||||
return repo_ids
|
||||
|
||||
|
||||
async def _translate_snapshots(
|
||||
items: list[dict], session: AsyncSession
|
||||
) -> list[SBOMSnapshotRead]:
|
||||
repo_ids = await _local_repo_ids(items, session)
|
||||
return [
|
||||
SBOMSnapshotRead.model_validate(_translate_snapshot(item, repo_ids[item["repo_slug"]]))
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
def _translate_snapshot(item: dict, repo_id: uuid.UUID) -> dict:
|
||||
translated = {**item, "repo_id": repo_id}
|
||||
source = translated.get("source")
|
||||
if (
|
||||
translated.get("legacy_id")
|
||||
and isinstance(source, str)
|
||||
and source.startswith("state-hub:")
|
||||
):
|
||||
translated["source"] = source.removeprefix("state-hub:")
|
||||
return translated
|
||||
|
||||
|
||||
def _translate_entry(item: dict, repo_id: uuid.UUID) -> dict:
|
||||
return {**item, "repo_id": repo_id}
|
||||
|
||||
|
||||
async def _meter_compat(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
method: str,
|
||||
route: str,
|
||||
) -> None:
|
||||
"""Meter the compatibility surface without making route success depend on it."""
|
||||
interface_key = f"rest_api:{method} {route}"
|
||||
try:
|
||||
await record_legacy_usage(
|
||||
session,
|
||||
interface_key=interface_key,
|
||||
interface_kind="rest_api",
|
||||
replacement_ref="sbom-nexus:/sbom/",
|
||||
owner_component="state-hub.sbom-compat",
|
||||
replacement_verified=reads_from_nexus(),
|
||||
identity=identity_from_request(request),
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ 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,
|
||||
|
|
@ -22,6 +21,7 @@ from api.models.service_catalog import (
|
|||
ServiceThirdParty,
|
||||
)
|
||||
from api.schemas.service import ServiceCatalogRead, ServiceUpsert
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ async def list_services(
|
|||
development_type: str | None = None,
|
||||
maturity_level: int | None = None,
|
||||
status: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ServiceCatalog]:
|
||||
q = select(ServiceCatalog).options(*_WITH_EXTENSIONS)
|
||||
|
|
@ -53,6 +54,11 @@ async def list_services(
|
|||
q = q.where(ServiceCatalog.maturity_level == maturity_level)
|
||||
if status:
|
||||
q = q.where(ServiceCatalog.status == status)
|
||||
if repo_slug:
|
||||
resolution = await resolve_repository_slug(session, repo_slug)
|
||||
q = q.join(ServiceFirstParty).where(
|
||||
ServiceFirstParty.repo_id == resolution.repo.id
|
||||
)
|
||||
q = q.order_by(ServiceCatalog.name.asc())
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
|
@ -131,12 +137,10 @@ async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: A
|
|||
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
|
||||
resolution = await resolve_repository_slug(
|
||||
session, body.first_party.repo_slug
|
||||
)
|
||||
data["repo_id"] = resolution.repo.id
|
||||
await _upsert_ext(ServiceFirstParty, svc.id, data, session)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload, selectinload
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
from api.services.schema_state import schema_state
|
||||
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
|
||||
|
|
@ -53,6 +55,9 @@ from api.services.summary_cache import (
|
|||
get_summary_cache,
|
||||
register_summary_cache_invalidation,
|
||||
)
|
||||
from api.services.ops_run_projection import get_ops_run_projection
|
||||
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
|
||||
from api.services.sbom_nexus import reads_from_nexus
|
||||
|
||||
|
||||
def _dual_workplan_refs(
|
||||
|
|
@ -70,6 +75,12 @@ def _dual_workplan_refs(
|
|||
"workstream_slug": slug,
|
||||
}
|
||||
from api.task_status import TERMINAL_TASK_STATUSES, status_value
|
||||
from api.work_record_flavor import (
|
||||
RESIDUAL_FLAVOR,
|
||||
WORK_RECORD_FLAVORS,
|
||||
is_residual_flavor,
|
||||
normalize_flavor,
|
||||
)
|
||||
from api.workplan_status import (
|
||||
CLOSED_WORKPLAN_STATUSES,
|
||||
OPEN_WORKPLAN_STATUSES,
|
||||
|
|
@ -95,12 +106,44 @@ def _summary_cache_headers(
|
|||
response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120"
|
||||
|
||||
|
||||
def _apply_summary_flavor_view(
|
||||
summary: StateSummary,
|
||||
*,
|
||||
include_residuals: bool,
|
||||
flavor: str | None,
|
||||
) -> StateSummary:
|
||||
"""Default views omit residual workplans; totals.residual_open stays honest."""
|
||||
wanted = normalize_flavor(flavor)
|
||||
if wanted is not None and wanted not in WORK_RECORD_FLAVORS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Unknown work-record flavor {wanted!r}; "
|
||||
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||
),
|
||||
)
|
||||
show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR
|
||||
residual_rows = list(summary.residual_open_workplans)
|
||||
open_rows = list(summary.open_workplans)
|
||||
if wanted is not None:
|
||||
open_rows = [row for row in open_rows if row.flavor == wanted]
|
||||
residual_rows = [row for row in residual_rows if row.flavor == wanted]
|
||||
return summary.model_copy(
|
||||
update={
|
||||
"open_workplans": open_rows,
|
||||
"residual_open_workplans": residual_rows if show_residuals else [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=StateSummary)
|
||||
async def get_summary(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
refresh: bool = False,
|
||||
include_residuals: bool = Query(False),
|
||||
flavor: str | None = Query(None),
|
||||
) -> StateSummary:
|
||||
revision = await fetch_summary_revision(session)
|
||||
revision_token = revision.combined_fingerprint()
|
||||
|
|
@ -111,22 +154,38 @@ async def get_summary(
|
|||
|
||||
if cache_status == "hit-revision" and cached is not None:
|
||||
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
|
||||
return cached
|
||||
return _apply_summary_flavor_view(
|
||||
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
||||
include_residuals=include_residuals,
|
||||
flavor=flavor,
|
||||
)
|
||||
|
||||
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
|
||||
return _apply_summary_flavor_view(
|
||||
result.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
||||
include_residuals=include_residuals,
|
||||
flavor=flavor,
|
||||
)
|
||||
|
||||
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
|
||||
return _apply_summary_flavor_view(
|
||||
cached.model_copy(update={"ops_runs": await get_ops_run_projection()}),
|
||||
include_residuals=include_residuals,
|
||||
flavor=flavor,
|
||||
)
|
||||
|
||||
result = await build_state_summary(session)
|
||||
cache.store(result, revision)
|
||||
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
|
||||
return result
|
||||
return _apply_summary_flavor_view(
|
||||
result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}),
|
||||
include_residuals=include_residuals,
|
||||
flavor=flavor,
|
||||
)
|
||||
|
||||
|
||||
async def build_state_summary(session: AsyncSession) -> StateSummary:
|
||||
|
|
@ -331,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
|||
),
|
||||
archived=ws_counts.get("archived", 0),
|
||||
total=sum(ws_counts.values()),
|
||||
residual_open=0,
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
wait=task_counts.get(TaskStatus.wait, 0),
|
||||
|
|
@ -363,23 +423,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
|||
)}
|
||||
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
|
||||
|
||||
# Licence risk: copyleft packages in direct prod deps
|
||||
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
|
||||
copyleft_risk_rows = await session.execute(
|
||||
select(func.count()).select_from(SBOMEntry)
|
||||
.where(SBOMEntry.is_direct.is_(True))
|
||||
.where(SBOMEntry.is_dev.is_(False))
|
||||
)
|
||||
# Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy
|
||||
all_direct_prod_rows = await session.execute(
|
||||
select(SBOMEntry.license_spdx)
|
||||
.where(SBOMEntry.is_direct.is_(True))
|
||||
.where(SBOMEntry.is_dev.is_(False))
|
||||
)
|
||||
licence_risk_count = sum(
|
||||
1 for (lic,) in all_direct_prod_rows.all()
|
||||
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
|
||||
)
|
||||
licence_risk_count, _, _ = await _sbom_metrics(session)
|
||||
|
||||
# Open capability requests (non-terminal statuses)
|
||||
open_cap_req_count = (await session.execute(
|
||||
|
|
@ -408,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
|||
)
|
||||
for w in open_ws
|
||||
]
|
||||
relevant_rows = [
|
||||
row for row, w in zip(open_workplan_rows, open_ws)
|
||||
if not is_residual_flavor(w.flavor)
|
||||
]
|
||||
residual_rows = [
|
||||
row for row, w in zip(open_workplan_rows, open_ws)
|
||||
if is_residual_flavor(w.flavor)
|
||||
]
|
||||
totals.workstreams.residual_open = len(residual_rows)
|
||||
|
||||
result = StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
|
|
@ -429,7 +482,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
|
|||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
ranked_suggestions=ranked_suggestions,
|
||||
open_workplans=open_workplan_rows,
|
||||
open_workplans=relevant_rows,
|
||||
residual_open_workplans=residual_rows,
|
||||
)
|
||||
return result
|
||||
|
||||
|
|
@ -532,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
w for w in workstreams_all
|
||||
if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES
|
||||
]
|
||||
residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor))
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
|
|
@ -591,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
),
|
||||
archived=ws_counts.get("archived", 0),
|
||||
total=sum(ws_counts.values()),
|
||||
residual_open=residual_open,
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
wait=task_totals_by_status.get("wait", 0),
|
||||
|
|
@ -645,23 +701,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
)}
|
||||
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
|
||||
|
||||
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
|
||||
all_direct_prod_rows = await session.execute(
|
||||
select(SBOMEntry.license_spdx)
|
||||
.where(SBOMEntry.is_direct.is_(True))
|
||||
.where(SBOMEntry.is_dev.is_(False))
|
||||
)
|
||||
licence_risk_count = sum(
|
||||
1 for (lic,) in all_direct_prod_rows.all()
|
||||
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
|
||||
)
|
||||
|
||||
snapshot_count, package_total = (await session.execute(
|
||||
select(
|
||||
func.count(SBOMSnapshot.id),
|
||||
func.coalesce(func.sum(SBOMSnapshot.entry_count), 0),
|
||||
)
|
||||
)).one()
|
||||
licence_risk_count, snapshot_count, package_total = await _sbom_metrics(session)
|
||||
|
||||
open_cap_req_count = (await session.execute(
|
||||
select(func.count()).select_from(CapabilityRequest).where(
|
||||
|
|
@ -695,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
id=w.id,
|
||||
title=w.title,
|
||||
status=normalize_workplan_status(w.status),
|
||||
flavor=w.flavor,
|
||||
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"),
|
||||
|
|
@ -741,6 +782,39 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview:
|
|||
)
|
||||
|
||||
|
||||
async def _sbom_metrics(session: AsyncSession) -> tuple[int, int, int]:
|
||||
"""Return compatibility summary metrics from the selected SBOM authority."""
|
||||
if reads_from_nexus():
|
||||
snapshots = await get_sbom_nexus_json("/sbom/snapshots/")
|
||||
report = await get_sbom_nexus_json("/sbom/report/licences/")
|
||||
return (
|
||||
int(report.get("copyleft_direct_count") or 0),
|
||||
len(snapshots),
|
||||
sum(int(snapshot.get("entry_count") or 0) for snapshot in snapshots),
|
||||
)
|
||||
|
||||
copyleft_patterns = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
|
||||
rows = await session.execute(
|
||||
select(SBOMEntry.license_spdx)
|
||||
.where(SBOMEntry.is_direct.is_(True))
|
||||
.where(SBOMEntry.is_dev.is_(False))
|
||||
)
|
||||
licence_risk_count = sum(
|
||||
1
|
||||
for (licence,) in rows.all()
|
||||
if licence and any(pattern in licence.upper() for pattern in copyleft_patterns)
|
||||
)
|
||||
snapshot_count, package_total = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(SBOMSnapshot.id),
|
||||
func.coalesce(func.sum(SBOMSnapshot.entry_count), 0),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
return licence_risk_count, int(snapshot_count or 0), int(package_total or 0)
|
||||
|
||||
|
||||
async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
||||
"""Compute per-domain stats for the state summary."""
|
||||
domains_rows = await session.execute(
|
||||
|
|
@ -789,7 +863,10 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
|||
|
||||
|
||||
@router.get("/deps", response_model=list[WorkstreamWithDeps])
|
||||
async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]:
|
||||
async def get_deps(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
include_residuals: bool = Query(False),
|
||||
) -> list[WorkstreamWithDeps]:
|
||||
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
|
||||
|
||||
Returns the same structure as open_workplans in /state/summary but skips
|
||||
|
|
@ -803,6 +880,8 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||
.order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
if not include_residuals:
|
||||
open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)]
|
||||
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
dep_rows = []
|
||||
|
|
@ -938,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
if task.id in seen_task_ids:
|
||||
continue
|
||||
ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")])
|
||||
if ws is not None and is_residual_flavor(ws.flavor):
|
||||
continue
|
||||
if is_residual_flavor(task.flavor):
|
||||
continue
|
||||
domain_slug = await _get_domain_slug_for_workplan(ws, session)
|
||||
steps.append(NextStep(
|
||||
type="resolved_decision",
|
||||
|
|
@ -982,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
Workplan.title,
|
||||
Workplan.slug,
|
||||
Workplan.topic_id,
|
||||
Workplan.flavor,
|
||||
).where(Workplan.id.in_(dep_ws_ids))
|
||||
)
|
||||
ws_info = {
|
||||
|
|
@ -990,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
"title": title,
|
||||
"slug": slug,
|
||||
"topic_id": topic_id,
|
||||
"flavor": flavor,
|
||||
}
|
||||
for ws_id, status, title, slug, topic_id in ws_rows
|
||||
for ws_id, status, title, slug, topic_id, flavor in ws_rows
|
||||
}
|
||||
|
||||
ready_from_ws_ids = [
|
||||
|
|
@ -1017,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis
|
|||
|
||||
for from_ws_id in ready_from_ws_ids:
|
||||
from_ws = ws_info.get(from_ws_id, {})
|
||||
if is_residual_flavor(from_ws.get("flavor")):
|
||||
continue
|
||||
todo_tasks = todo_by_ws.get(from_ws_id, [])
|
||||
if not todo_tasks:
|
||||
continue
|
||||
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||
if task.id in seen_task_ids:
|
||||
continue
|
||||
if is_residual_flavor(task.flavor):
|
||||
continue
|
||||
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
|
||||
_blocker_slugs = []
|
||||
for tid in dep_map[from_ws_id]:
|
||||
|
|
@ -1104,7 +1193,16 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
|||
async def health_check(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "connected"}
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "connected",
|
||||
# Identity, so a caller can verify it reached the hub it meant to.
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
# Surfaced, not merely logged: a schema behind the code is a fault
|
||||
# the operator must be able to see (STATE-WP-0083-T07).
|
||||
"schema": await schema_state(session),
|
||||
}
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
|
|
|
|||
|
|
@ -1,259 +1,45 @@
|
|||
import uuid
|
||||
"""Retired: the suggestion backlog.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
Slice E1 of the State Hub retirement (`STATE-WP-0079-T05`). The capability was
|
||||
superseded by the **intake** work-record entity; mutations were retired
|
||||
2026-07-21 under `CUST-WP-0061-T06`, and the reads were kept live only so the
|
||||
historical record stayed reachable.
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.suggestion import (
|
||||
OPEN_SUGGESTION_STAGES,
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
SuggestionStage,
|
||||
)
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.schemas.suggestion import (
|
||||
SuggestionBumpRelevance,
|
||||
SuggestionCreate,
|
||||
SuggestionDecline,
|
||||
SuggestionNoteRead,
|
||||
SuggestionPromote,
|
||||
SuggestionRead,
|
||||
SuggestionVet,
|
||||
)
|
||||
from api.services.suggestion_relevance import bump_relevance
|
||||
from api.services.suggestion_wsjf import compute_wsjf, cost_of_delay, suggestion_sort_key
|
||||
from api.task_status import normalize_task_status
|
||||
That history is now archived at
|
||||
`the-custodian/docs/archived-suggestion-backlog.md` — all 10 suggestions, 10
|
||||
notes and 5 relevance bumps, every one closed as `declined` during the intake
|
||||
migration and none promoted. With a durable record outside this repo, the read
|
||||
surface has no remaining job, so the whole router answers 410.
|
||||
|
||||
The `suggestions`, `suggestion_notes` and `suggestion_relevance_bumps` tables
|
||||
are deliberately left in place: they are `retire`/`archive` in `SHR-INV-0001`
|
||||
and are captured by the final dump at `STATE-WP-0079-T06`. Dropping them here
|
||||
would remove data ahead of the dump for no gain.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/suggestions", tags=["suggestions"])
|
||||
|
||||
_ALLOWED_VET_FROM = {SuggestionStage.suggestion}
|
||||
_ALLOWED_DECLINE_FROM = {SuggestionStage.suggestion, SuggestionStage.requirement}
|
||||
_ALLOWED_PROMOTE_FROM = {SuggestionStage.requirement}
|
||||
_DETAIL = (
|
||||
"suggestions are retired (STATE-WP-0079-T05, slice E1). "
|
||||
"Use POST /intakes/ for new discovery work. The historical backlog is "
|
||||
"archived at the-custodian/docs/archived-suggestion-backlog.md — see also "
|
||||
"the-custodian/intake-legacy-suggestions-migration.md and "
|
||||
"canon/standards/work-record-types_v0.1.md."
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
|
||||
row = await session.execute(
|
||||
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
|
||||
)
|
||||
domain_id = row.scalar_one_or_none()
|
||||
if domain_id is None:
|
||||
valid = [r[0] for r in (await session.execute(
|
||||
select(Domain.slug).where(Domain.status == "active")
|
||||
)).all()]
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
|
||||
)
|
||||
return domain_id
|
||||
def _retired() -> HTTPException:
|
||||
return HTTPException(status_code=410, detail=_DETAIL)
|
||||
|
||||
|
||||
def _enrich_read(suggestion: Suggestion) -> SuggestionRead:
|
||||
data = SuggestionRead.model_validate(suggestion)
|
||||
data.cost_of_delay = cost_of_delay(suggestion)
|
||||
data.wsjf = compute_wsjf(suggestion)
|
||||
return data
|
||||
|
||||
|
||||
async def _get_suggestion_or_404(
|
||||
suggestion_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> Suggestion:
|
||||
suggestion = await session.get(Suggestion, suggestion_id)
|
||||
if suggestion is None:
|
||||
raise HTTPException(status_code=404, detail="Suggestion not found")
|
||||
return suggestion
|
||||
|
||||
|
||||
def _reject_stage(suggestion: Suggestion, allowed: set[SuggestionStage], action: str) -> None:
|
||||
if suggestion.stage not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot {action} suggestion in stage '{suggestion.stage.value}'",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SuggestionRead])
|
||||
async def list_suggestions(
|
||||
domain: str | None = None,
|
||||
stage: SuggestionStage | None = None,
|
||||
include_terminal: bool = Query(False),
|
||||
rank: str | None = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SuggestionRead]:
|
||||
q = select(Suggestion)
|
||||
if domain:
|
||||
domain_id = await _resolve_domain_id(domain, session)
|
||||
q = q.where(Suggestion.domain_id == domain_id)
|
||||
if stage:
|
||||
q = q.where(Suggestion.stage == stage)
|
||||
elif not include_terminal:
|
||||
q = q.where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
|
||||
result = await session.execute(q)
|
||||
suggestions = list(result.scalars().all())
|
||||
if rank == "wsjf":
|
||||
suggestions.sort(key=suggestion_sort_key)
|
||||
else:
|
||||
suggestions.sort(key=lambda s: s.created_at)
|
||||
return [_enrich_read(s) for s in suggestions[:limit]]
|
||||
|
||||
|
||||
@router.post("/", response_model=SuggestionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_suggestion(
|
||||
body: SuggestionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
suggestion = Suggestion(
|
||||
domain_id=domain_id,
|
||||
topic_id=body.topic_id,
|
||||
workplan_id=body.workplan_id,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
origin=body.origin,
|
||||
origin_ref=body.origin_ref,
|
||||
base_value=body.base_value,
|
||||
job_size=body.job_size,
|
||||
relevance_weight=body.relevance_weight,
|
||||
)
|
||||
session.add(suggestion)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}", response_model=SuggestionRead)
|
||||
async def get_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/vet", response_model=SuggestionRead)
|
||||
async def vet_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionVet,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_VET_FROM, "vet")
|
||||
suggestion.stage = SuggestionStage.requirement
|
||||
if body.base_value is not None:
|
||||
suggestion.base_value = body.base_value
|
||||
if body.job_size is not None:
|
||||
suggestion.job_size = body.job_size
|
||||
if body.relevance_weight is not None:
|
||||
suggestion.relevance_weight = body.relevance_weight
|
||||
if body.workplan_id is not None:
|
||||
suggestion.workplan_id = body.workplan_id
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.requirement.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/decline", response_model=SuggestionRead)
|
||||
async def decline_suggestion(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionDecline,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_DECLINE_FROM, "decline")
|
||||
suggestion.stage = SuggestionStage.declined
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.declined.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/promote", response_model=SuggestionRead)
|
||||
async def promote_suggestion_to_task(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionPromote,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
_reject_stage(suggestion, _ALLOWED_PROMOTE_FROM, "promote")
|
||||
if suggestion.workplan_id is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Suggestion must have workplan_id before promotion",
|
||||
)
|
||||
|
||||
task = Task(
|
||||
workplan_id=suggestion.workplan_id,
|
||||
title=body.task_title or suggestion.title,
|
||||
description=body.task_description or suggestion.description,
|
||||
status=TaskStatus(normalize_task_status(body.task_status)),
|
||||
priority=TaskPriority(body.task_priority),
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
suggestion.stage = SuggestionStage.promoted
|
||||
suggestion.promoted_task_id = task.id
|
||||
if body.note:
|
||||
session.add(
|
||||
SuggestionNote(
|
||||
suggestion_id=suggestion.id,
|
||||
stage=SuggestionStage.promoted.value,
|
||||
author=body.author,
|
||||
content=body.note,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/bump-relevance", response_model=SuggestionRead)
|
||||
async def bump_suggestion_relevance(
|
||||
suggestion_id: uuid.UUID,
|
||||
body: SuggestionBumpRelevance,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SuggestionRead:
|
||||
suggestion = await _get_suggestion_or_404(suggestion_id, session)
|
||||
source_key = body.author or "explicit"
|
||||
await bump_relevance(
|
||||
session,
|
||||
suggestion,
|
||||
source="explicit",
|
||||
source_key=source_key,
|
||||
reason=body.reason,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(suggestion)
|
||||
return _enrich_read(suggestion)
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}/notes", response_model=list[SuggestionNoteRead])
|
||||
async def list_suggestion_notes(
|
||||
suggestion_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SuggestionNote]:
|
||||
await _get_suggestion_or_404(suggestion_id, session)
|
||||
result = await session.execute(
|
||||
select(SuggestionNote)
|
||||
.where(SuggestionNote.suggestion_id == suggestion_id)
|
||||
.order_by(SuggestionNote.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@router.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@router.api_route("/", methods=["GET", "POST", "PATCH", "PUT", "DELETE"])
|
||||
async def suggestions_retired(path: str = "") -> None:
|
||||
"""Every suggestion route is retired; see module docstring."""
|
||||
raise _retired()
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ async def bulk_status_sync(
|
|||
updated: list[Task] = []
|
||||
events: list[ProgressEvent] = []
|
||||
author = body.author or "custodian"
|
||||
# Cache repo resolution for dual-run writeback (RMGR-WP-0003)
|
||||
repo_cache: dict = {}
|
||||
for update in body.updates:
|
||||
task = tasks_by_id[update.task_id]
|
||||
previous_status = status_value(task.status)
|
||||
|
|
@ -182,6 +184,32 @@ async def bulk_status_sync(
|
|||
parent_workstream=ws,
|
||||
previous_task_status=previous_status,
|
||||
)
|
||||
if target_status != previous_status:
|
||||
try:
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.services.repo_manager_dual_run import try_writeback_for_task
|
||||
from api.services.workplan_files import resolve_repo_path
|
||||
|
||||
repo_id = ws.repo_id if ws else None
|
||||
if repo_id is not None:
|
||||
if repo_id not in repo_cache:
|
||||
repo_cache[repo_id] = await session.get(ManagedRepo, repo_id)
|
||||
repo = repo_cache[repo_id]
|
||||
if repo is not None:
|
||||
try_writeback_for_task(
|
||||
repo_path=resolve_repo_path(repo),
|
||||
repo_slug=repo.slug,
|
||||
task_id=str(task.id),
|
||||
status=target_status,
|
||||
reason="state-hub bulk-status-sync dual-run",
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"repo-manager dual-run bulk writeback failed for task %s",
|
||||
update.task_id,
|
||||
)
|
||||
event = ProgressEvent(
|
||||
task_id=task.id,
|
||||
workplan_id=task.workplan_id,
|
||||
|
|
@ -263,6 +291,33 @@ async def update_task(
|
|||
parent_workstream=ws,
|
||||
previous_task_status=previous_status,
|
||||
)
|
||||
# RMGR-WP-0002 dual-run: optional checkout writeback via repo-manager
|
||||
if new_status != previous_status:
|
||||
try:
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.services.repo_manager_dual_run import try_writeback_for_task
|
||||
from api.services.workplan_files import resolve_repo_path
|
||||
|
||||
ws_full = ws or await session.get(Workplan, task.workplan_id)
|
||||
repo = None
|
||||
if ws_full and ws_full.repo_id:
|
||||
repo = await session.get(ManagedRepo, ws_full.repo_id)
|
||||
if repo is not None:
|
||||
repo_path = resolve_repo_path(repo)
|
||||
try_writeback_for_task(
|
||||
repo_path=repo_path,
|
||||
repo_slug=repo.slug,
|
||||
task_id=str(task.id),
|
||||
status=new_status,
|
||||
reason="state-hub PATCH /tasks dual-run",
|
||||
)
|
||||
except Exception:
|
||||
# Dual-run must not break native DB update path
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"repo-manager dual-run writeback failed for task %s", task_id
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from api.models.task import Task
|
|||
from api.models.token_event import TokenEvent
|
||||
from api.models.workplan import Workplan
|
||||
from api.services.legacy_compat import meter_legacy_query_param
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
from api.schemas.token_event import (
|
||||
RepoTokenSummary,
|
||||
TokenAggregateRow,
|
||||
|
|
@ -288,6 +289,7 @@ async def get_token_summary(
|
|||
|
||||
@router.get("/by-repo/", response_model=list[RepoTokenSummary])
|
||||
async def get_tokens_by_repo(
|
||||
repo_slug: str | None = None,
|
||||
measurement_kind: str | None = None,
|
||||
source_provider: str | None = None,
|
||||
since: datetime | None = None,
|
||||
|
|
@ -304,6 +306,12 @@ async def get_tokens_by_repo(
|
|||
|
||||
Only events that resolve to a repo are included.
|
||||
"""
|
||||
requested_repo_id = None
|
||||
if repo_slug:
|
||||
requested_repo_id = (
|
||||
await resolve_repository_slug(session, repo_slug)
|
||||
).repo.id
|
||||
|
||||
# Fetch all events, workstreams, repos in three queries (avoids N+1)
|
||||
events_result = await session.execute(
|
||||
_filter_query(
|
||||
|
|
@ -341,6 +349,8 @@ async def get_tokens_by_repo(
|
|||
rid = resolve_repo_id(e)
|
||||
if not rid or rid not in repo_map:
|
||||
continue
|
||||
if requested_repo_id is not None and rid != requested_repo_id:
|
||||
continue
|
||||
if rid not in groups:
|
||||
groups[rid] = {
|
||||
"repo_id": rid,
|
||||
|
|
|
|||
151
api/routers/work_record_projection.py
Normal file
151
api/routers/work_record_projection.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Primary-only fast path for one forge-derived repository projection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
from api.models.task import Task
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.work_record_projection import (
|
||||
RepositoryProjectionReconcile,
|
||||
RepositoryProjectionSnapshot,
|
||||
)
|
||||
from api.services.forge_projection import (
|
||||
ForgeDeriveError,
|
||||
ForgeUnreadableError,
|
||||
derive_from_forge,
|
||||
reset_repository_projection,
|
||||
)
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/repos", tags=["repository-work-record-projection"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{slug}/work-record-projection/snapshot",
|
||||
response_model=RepositoryProjectionSnapshot,
|
||||
)
|
||||
async def repository_work_record_snapshot(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepositoryProjectionSnapshot:
|
||||
"""Return all consistency-check inputs for one repository in one request."""
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
workplans = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Workplan)
|
||||
.where(Workplan.repo_id == resolution.repo.id)
|
||||
.order_by(Workplan.slug)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
workplan_ids = [workplan.id for workplan in workplans]
|
||||
tasks: list[Task] = []
|
||||
dependencies: list[WorkplanDependency] = []
|
||||
if workplan_ids:
|
||||
tasks = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Task)
|
||||
.where(Task.workplan_id.in_(workplan_ids))
|
||||
.order_by(Task.workplan_id, Task.id)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
dependencies = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(WorkplanDependency)
|
||||
.where(WorkplanDependency.from_workplan_id.in_(workplan_ids))
|
||||
.order_by(
|
||||
WorkplanDependency.from_workplan_id, WorkplanDependency.id
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
return RepositoryProjectionSnapshot(
|
||||
schema="state-hub.repository-projection-snapshot.v1",
|
||||
repo_slug=resolution.canonical_slug,
|
||||
repo_id=resolution.repo.id,
|
||||
workplans=workplans,
|
||||
tasks=tasks,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/work-record-projection/reconcile")
|
||||
async def reconcile_repository_work_records(
|
||||
slug: str,
|
||||
body: RepositoryProjectionReconcile,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Derive an exact pushed commit centrally and apply it transactionally."""
|
||||
if settings.state_hub_instance_role != "primary":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "repository projection writes require the primary State Hub",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
derived = await asyncio.to_thread(derive_from_forge, slug)
|
||||
except ForgeUnreadableError as exc:
|
||||
raise HTTPException(
|
||||
status_code=424,
|
||||
detail={
|
||||
"message": "repository is unreadable from the forge",
|
||||
"detail": str(exc)[:300],
|
||||
},
|
||||
) from exc
|
||||
except ForgeDeriveError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"message": "forge projection derivation failed",
|
||||
"detail": str(exc)[:300],
|
||||
},
|
||||
) from exc
|
||||
|
||||
if derived.commit.lower() != body.expected_commit:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "forge default branch is not at the expected commit",
|
||||
"expected_commit": body.expected_commit,
|
||||
"derived_commit": derived.commit,
|
||||
},
|
||||
)
|
||||
|
||||
outcome = await reset_repository_projection(
|
||||
session,
|
||||
slug,
|
||||
acknowledge_retirements=body.acknowledge_retirements,
|
||||
derived=derived,
|
||||
)
|
||||
if outcome.status in {"applied", "noop"} or outcome.released:
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
|
||||
from api.routers.workstreams import _invalidate_workplan_index_cache
|
||||
|
||||
_invalidate_workplan_index_cache()
|
||||
return {
|
||||
"schema": "state-hub.repository-projection-reconcile.v1",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
"expected_commit": body.expected_commit,
|
||||
"derived_commit": derived.commit,
|
||||
"outcome": outcome.to_dict(),
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ from typing import Any
|
|||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
|
|
@ -24,6 +24,11 @@ from api.schemas.workplan import (
|
|||
)
|
||||
from api.services.lifecycle import transition_workplan_status
|
||||
from api.services.legacy_compat import retire_legacy_route
|
||||
from api.work_record_flavor import (
|
||||
RESIDUAL_FLAVOR,
|
||||
WORK_RECORD_FLAVORS,
|
||||
normalize_flavor,
|
||||
)
|
||||
from api.workplan_status import (
|
||||
is_supported_workplan_status,
|
||||
normalize_workplan_status,
|
||||
|
|
@ -88,6 +93,8 @@ async def _list_workplans(
|
|||
status_filter: str | None,
|
||||
owner: str | None,
|
||||
slug: str | None,
|
||||
flavor: str | None = None,
|
||||
include_residuals: bool = True,
|
||||
session: AsyncSession,
|
||||
) -> list[Workplan]:
|
||||
q = select(Workplan)
|
||||
|
|
@ -106,6 +113,19 @@ async def _list_workplans(
|
|||
q = q.where(Workplan.owner == owner)
|
||||
if slug:
|
||||
q = q.where(Workplan.slug == slug)
|
||||
wanted = normalize_flavor(flavor)
|
||||
if wanted is not None:
|
||||
if wanted not in WORK_RECORD_FLAVORS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Unknown work-record flavor {wanted!r}; "
|
||||
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||
),
|
||||
)
|
||||
q = q.where(Workplan.flavor == wanted)
|
||||
elif not include_residuals:
|
||||
q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR))
|
||||
q = q.order_by(
|
||||
Workplan.planning_priority.asc().nullslast(),
|
||||
Workplan.planning_order.asc().nullslast(),
|
||||
|
|
@ -365,6 +385,8 @@ async def list_workplans(
|
|||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
flavor: str | None = Query(None),
|
||||
include_residuals: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workplan]:
|
||||
return await _list_workplans(
|
||||
|
|
@ -374,6 +396,8 @@ async def list_workplans(
|
|||
status_filter=status,
|
||||
owner=owner,
|
||||
slug=slug,
|
||||
flavor=flavor,
|
||||
include_residuals=include_residuals,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
|
@ -411,8 +435,11 @@ async def sync_workplan_bindings(
|
|||
"""Upsert workstation workplan file bindings for remote API index fallback."""
|
||||
synced_at = datetime.now(timezone.utc)
|
||||
updated = 0
|
||||
requested_ids = {entry.workplan_id for entry in body.bindings}
|
||||
rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids)))
|
||||
workplans = {workplan.id: workplan for workplan in rows.scalars().all()}
|
||||
for entry in body.bindings:
|
||||
wp = await session.get(Workplan, entry.workplan_id)
|
||||
wp = workplans.get(entry.workplan_id)
|
||||
if wp is None:
|
||||
continue
|
||||
wp.backing_filename = entry.filename
|
||||
|
|
@ -524,4 +551,4 @@ async def archive_workplan(
|
|||
workplan_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workplan:
|
||||
return await _archive_workplan(workplan_id=workplan_id, session=session)
|
||||
return await _archive_workplan(workplan_id=workplan_id, session=session)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field
|
|||
|
||||
|
||||
class DecisionCreate(LegacyWorkstreamIdBodyMixin, BaseModel):
|
||||
id: uuid.UUID | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = Field(
|
||||
default=None,
|
||||
|
|
@ -65,4 +66,4 @@ class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
|
|||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
updated_at: datetime
|
||||
|
|
|
|||
|
|
@ -104,3 +104,5 @@ class ExecutionSemantics(BaseModel):
|
|||
concurrency_modes: dict[str, str]
|
||||
state_hub_responsibility: list[str]
|
||||
activity_core_responsibility: list[str]
|
||||
launch_requests_accepted: bool = False
|
||||
replacements: dict[str, str] = Field(default_factory=dict)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ class FabricGraphSource(BaseModel):
|
|||
repo: str | None = None
|
||||
producer: str | None = None
|
||||
registry: str | None = None
|
||||
commit: str | None = None
|
||||
# Accepted-snapshot-set revisions include the algorithm and digest, for
|
||||
# example ``snapshot-set:sha256:<64 hex chars>``. They are intentionally
|
||||
# longer than a Git SHA but still bounded before persistence.
|
||||
commit: str | None = Field(default=None, max_length=255)
|
||||
path: str | None = None
|
||||
generation_reason: str | None = None
|
||||
|
||||
|
|
@ -98,6 +101,18 @@ class FabricGraphAccountingPayload(BaseModel):
|
|||
valid_until: str | None = None
|
||||
|
||||
|
||||
class FabricGraphDeploymentOverlayPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
deployment_environment: str | None = None
|
||||
deployment_scenario: str | None = None
|
||||
routing_authority: str | None = None
|
||||
access_zone: str | None = None
|
||||
policy_authority: str | None = None
|
||||
exposure_class: str | None = None
|
||||
route_evidence: dict[str, str | int | float | bool | None] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FabricGraphEvidencePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
|
@ -154,6 +169,7 @@ class FabricGraphNodePayload(BaseModel):
|
|||
containment: FabricGraphContainmentPayload | None = None
|
||||
ownership: FabricGraphOwnershipPayload | None = None
|
||||
accounting: FabricGraphAccountingPayload | None = None
|
||||
deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None
|
||||
evidence: FabricGraphEvidencePayload | None = None
|
||||
canon_category: str | None = None
|
||||
canon_anchor: str | None = None
|
||||
|
|
@ -180,6 +196,7 @@ class FabricGraphEdgePayload(BaseModel):
|
|||
boundary: FabricGraphBoundaryPayload | None = None
|
||||
utility: FabricGraphUtilityPayload | None = None
|
||||
accounting: FabricGraphAccountingPayload | None = None
|
||||
deployment_overlay: FabricGraphDeploymentOverlayPayload | None = None
|
||||
evidence: FabricGraphEvidencePayload | None = None
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
|
@ -244,6 +261,17 @@ class FabricGraphIngestResult(BaseModel):
|
|||
edge_count: int
|
||||
|
||||
|
||||
class FabricGraphActivationRequest(BaseModel):
|
||||
requested_by: str = "operator"
|
||||
|
||||
|
||||
class FabricGraphActivationResult(BaseModel):
|
||||
import_run: FabricGraphImportRead
|
||||
previous_import_id: uuid.UUID | None = None
|
||||
activated: bool
|
||||
idempotent: bool
|
||||
|
||||
|
||||
class FabricGraphNodeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
|
|||
39
api/schemas/identifier_migration.py
Normal file
39
api/schemas/identifier_migration.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SealedProjectionRepairSubmit(BaseModel):
|
||||
plan: dict[str, Any]
|
||||
repo_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,99}$")
|
||||
unit: dict[str, Any]
|
||||
expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
source_revision: str = Field(pattern=r"^[0-9a-f]{40}$")
|
||||
source_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
source_clean: bool
|
||||
source_synchronized: bool
|
||||
primary_confirmed: bool
|
||||
projection_identity: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class SealedProjectionRepairReceiptRead(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
schema_version: str = Field(alias="schema")
|
||||
outcome: str
|
||||
repo_slug: str
|
||||
repository_id: str
|
||||
workplan_record_id: str
|
||||
workplan_old_id: str
|
||||
task_records: list[tuple[str, str]]
|
||||
plan_sha256: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
projection_identity: str
|
||||
observed_at: str
|
||||
|
||||
|
||||
class IdentifierMigrationApplySubmit(BaseModel):
|
||||
plan: dict[str, Any]
|
||||
expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
primary_confirmed: bool
|
||||
89
api/schemas/intake.py
Normal file
89
api/schemas/intake.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.intake import IntakeLane, IntakeOutcome, IntakeStatus
|
||||
|
||||
|
||||
class IntakeCreate(BaseModel):
|
||||
id: uuid.UUID | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
lane: IntakeLane = IntakeLane.green
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
source_repo_path: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def scope_required(self) -> "IntakeCreate":
|
||||
if self.topic_id is None and self.workplan_id is None and self.repo_id is None:
|
||||
raise ValueError("At least one of topic_id, workplan_id, or repo_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
class IntakeUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
lane: IntakeLane | None = None
|
||||
status: IntakeStatus | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
routed_note: str | None = None
|
||||
|
||||
|
||||
class IntakeRoute(BaseModel):
|
||||
"""Move an intake from open/vetted into routed — the state that makes
|
||||
it eligible for the promotion transition (CUST-WP-0061-T03)."""
|
||||
|
||||
routed_note: str | None = None
|
||||
|
||||
|
||||
class IntakeClose(BaseModel):
|
||||
outcome: IntakeOutcome
|
||||
promoted_to: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def promoted_requires_target(self) -> "IntakeClose":
|
||||
if self.outcome == IntakeOutcome.promoted and not self.promoted_to:
|
||||
raise ValueError("outcome=promoted requires promoted_to")
|
||||
return self
|
||||
|
||||
|
||||
class IntakeNoteCreate(BaseModel):
|
||||
content: str
|
||||
author: str | None = None
|
||||
|
||||
|
||||
class IntakeNoteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
author: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class IntakeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
lane: IntakeLane
|
||||
status: IntakeStatus
|
||||
outcome: IntakeOutcome | None = None
|
||||
origin: str | None = None
|
||||
origin_ref: str | None = None
|
||||
promoted_to: str | None = None
|
||||
source_repo_path: str | None = None
|
||||
routed_note: str | None = None
|
||||
closed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
notes: list[IntakeNoteRead] = []
|
||||
|
|
@ -80,6 +80,7 @@ class RepoCreate(CoreRepoCreate, ClassificationFields):
|
|||
|
||||
|
||||
class RepoUpdate(ClassificationFields):
|
||||
domain_slug: str | None = None
|
||||
name: str | None = None
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
|
|
@ -113,6 +114,11 @@ class RepoRead(CoreRepoRead, ClassificationFields):
|
|||
last_state_synced_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
requested_slug: str | None = None
|
||||
canonical_slug: str | None = None
|
||||
slug_status: Literal["canonical", "alias"] = "canonical"
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
stale_external_references: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DispatchTask(BaseModel):
|
||||
|
|
@ -157,6 +163,11 @@ class ScopeIssueDetail(BaseModel):
|
|||
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
requested_slug: str
|
||||
canonical_slug: str
|
||||
slug_status: Literal["canonical", "alias"]
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
stale_external_references: list[dict[str, Any]] = Field(default_factory=list)
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workplans: list[DispatchWorkplan]
|
||||
human_interventions: list[DispatchTask]
|
||||
|
|
@ -176,4 +187,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]
|
||||
|
|
|
|||
27
api/schemas/ops_run.py
Normal file
27
api/schemas/ops_run.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OpsRunProjectionItem(BaseModel):
|
||||
id: str
|
||||
definition: str | None = None
|
||||
target_repo: str | None = None
|
||||
state: str
|
||||
lease: dict[str, Any] | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
class OpsRunProjection(BaseModel):
|
||||
available: bool = False
|
||||
stale: bool = False
|
||||
source: str = "activity-core"
|
||||
generated_at: datetime
|
||||
open: int = 0
|
||||
claimed: int = 0
|
||||
failed_24h: int = 0
|
||||
stuck_open_or_claimed: int = 0
|
||||
items: list[OpsRunProjectionItem] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
114
api/schemas/repository_rename.py
Normal file
114
api/schemas/repository_rename.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EdgeWriteEvidence(BaseModel):
|
||||
id: str
|
||||
status: str = "queued"
|
||||
source_host: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class RepositoryRenamePreflightRequest(BaseModel):
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepositoryRenamePreflightRead(BaseModel):
|
||||
schema_version: Literal["state-hub.repository-rename-preflight.v1"]
|
||||
repo_id: uuid.UUID
|
||||
old_slug: str
|
||||
new_slug: str
|
||||
safe_to_apply: bool
|
||||
blockers: list[dict[str, Any]]
|
||||
warnings: list[dict[str, Any]]
|
||||
current: dict[str, Any]
|
||||
target: dict[str, Any]
|
||||
baselines: dict[str, Any]
|
||||
active_work: dict[str, Any]
|
||||
affected: dict[str, Any]
|
||||
queued_edge_writes: list[dict[str, Any]]
|
||||
proposed_mutations: list[dict[str, Any]]
|
||||
retained_history: list[dict[str, Any]]
|
||||
report_checksum: str
|
||||
preflight_token: str | None
|
||||
preflighted_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
class ForgeIdentityVerifyRequest(BaseModel):
|
||||
provider: Literal["forgejo"] = "forgejo"
|
||||
forge_instance: str
|
||||
forge_owner: str
|
||||
forge_repository_id: int = Field(gt=0)
|
||||
verified_by: str = Field(min_length=1, max_length=160)
|
||||
|
||||
|
||||
class RepositoryRenameOperationCreate(BaseModel):
|
||||
operation_id: uuid.UUID | None = None
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
preflight_token: str
|
||||
confirmation: str
|
||||
actor: str = Field(min_length=1, max_length=160)
|
||||
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepositoryRenamePhaseApply(BaseModel):
|
||||
expected_phase: str
|
||||
confirmation: str
|
||||
checks: dict[str, bool] = Field(default_factory=dict)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RepositoryRenameRollbackRequest(BaseModel):
|
||||
expected_phase: str
|
||||
confirmation: str
|
||||
|
||||
|
||||
class RepositoryRenameOperationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
phase: str
|
||||
old_slug: str
|
||||
new_slug: str
|
||||
expected_forge_repository_id: int
|
||||
expected_source_commit: str
|
||||
expected_default_branch: str
|
||||
actor: str
|
||||
phase_changed_at: datetime
|
||||
preflighted_at: datetime | None
|
||||
preflight_expires_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
rolled_back_at: datetime | None
|
||||
evidence: dict[str, Any]
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
error_details: dict[str, Any] | None
|
||||
error_at: datetime | None
|
||||
no_op: bool = False
|
||||
|
||||
|
||||
class RepositoryRenameVerificationRead(BaseModel):
|
||||
operation_id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
phase: str
|
||||
ok: bool
|
||||
checks: list[dict[str, Any]]
|
||||
baseline_checksum: str
|
||||
current_checksum: str
|
||||
relationship_checksums: dict[str, dict[str, str]]
|
||||
|
||||
|
||||
class RepositoryRenameRollbackPreflightRead(BaseModel):
|
||||
operation_id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
rollback_from_phase: str
|
||||
safe_to_rollback: bool
|
||||
blockers: list[dict[str, Any]]
|
||||
irreversible: list[dict[str, Any]]
|
||||
operation: RepositoryRenameOperationRead
|
||||
96
api/schemas/review_contract.py
Normal file
96
api/schemas/review_contract.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SourceRef(BaseModel):
|
||||
repo: str = Field(min_length=1, max_length=100)
|
||||
path: str = Field(min_length=1, max_length=1000)
|
||||
revision: str = Field(pattern=r"^[0-9a-f]{40,64}$")
|
||||
|
||||
|
||||
class ReviewContractProject(BaseModel):
|
||||
contract: dict[str, Any]
|
||||
source: SourceRef
|
||||
decision_id: uuid.UUID | None = None
|
||||
workplan_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
required_for_decision: bool = False
|
||||
|
||||
|
||||
class ReviewContractRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
contract_key: str
|
||||
schema_version: str
|
||||
contract_digest: str
|
||||
source_repo: str
|
||||
source_path: str
|
||||
source_revision: str
|
||||
document: dict[str, Any]
|
||||
active: bool
|
||||
required_for_decision: bool
|
||||
decision_id: uuid.UUID | None
|
||||
workplan_id: uuid.UUID | None
|
||||
task_id: uuid.UUID | None
|
||||
projected_at: datetime
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ReviewReceiptSubmit(BaseModel):
|
||||
owner_id: str = Field(min_length=1, max_length=160)
|
||||
actor: str = Field(min_length=2, max_length=160)
|
||||
disposition: Literal["approve", "request_changes"]
|
||||
contract_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
artifact_hashes: dict[str, str] = Field(default_factory=dict)
|
||||
assertion_ids: list[str] = Field(default_factory=list)
|
||||
checks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
source: SourceRef
|
||||
|
||||
|
||||
class ReviewReceiptRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
contract_id: uuid.UUID
|
||||
owner_id: str
|
||||
actor: str
|
||||
disposition: str
|
||||
contract_digest: str
|
||||
receipt_digest: str
|
||||
artifact_hashes: dict[str, str]
|
||||
assertion_ids: list[str]
|
||||
checks: list[dict[str, Any]]
|
||||
note: str | None
|
||||
source_repo: str
|
||||
source_path: str
|
||||
source_revision: str
|
||||
submitted_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class OwnerReviewState(BaseModel):
|
||||
owner_id: str
|
||||
status: Literal["missing", "approved", "request_changes", "stale"]
|
||||
receipt_id: uuid.UUID | None = None
|
||||
receipt_digest: str | None = None
|
||||
submitted_at: datetime | None = None
|
||||
|
||||
|
||||
class GateReviewState(BaseModel):
|
||||
gate_id: str
|
||||
policy: Literal["all_required"]
|
||||
owners: list[str]
|
||||
satisfied: bool
|
||||
|
||||
|
||||
class ReviewAggregateRead(BaseModel):
|
||||
contract_key: str
|
||||
contract_digest: str
|
||||
satisfied: bool
|
||||
authorizes_execution: Literal[False] = False
|
||||
owners: list[OwnerReviewState]
|
||||
gates: list[GateReviewState]
|
||||
|
|
@ -11,6 +11,7 @@ from api.schemas.task import TaskRead
|
|||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.suggestion import RankedSuggestionDigest
|
||||
from api.schemas.workstream import WorkstreamWithDeps
|
||||
from api.schemas.ops_run import OpsRunProjection
|
||||
|
||||
|
||||
class TopicTotals(BaseModel):
|
||||
|
|
@ -29,6 +30,7 @@ class WorkstreamTotals(BaseModel):
|
|||
finished: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
residual_open: int = 0
|
||||
|
||||
|
||||
class TaskTotals(BaseModel):
|
||||
|
|
@ -84,18 +86,21 @@ class StateSummary(BaseModel):
|
|||
blocked_tasks: list[TaskRead] = []
|
||||
recent_progress: list[ProgressEventRead]
|
||||
open_workplans: list[WorkstreamWithDeps] = []
|
||||
residual_open_workplans: list[WorkstreamWithDeps] = []
|
||||
next_steps: list[NextStep] = []
|
||||
domains: list[DomainSummary] = []
|
||||
contribution_counts: dict[str, int] = {}
|
||||
licence_risk_count: int = 0
|
||||
open_capability_requests: int = 0
|
||||
ranked_suggestions: list[RankedSuggestionDigest] = []
|
||||
ops_runs: OpsRunProjection | None = None
|
||||
|
||||
|
||||
class DashboardWorkplanRow(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
status: str
|
||||
flavor: str | None = None
|
||||
domain: str = "unknown"
|
||||
repo_label: str = "unassigned"
|
||||
workplan_filename: str | None = None
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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
|
||||
from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor
|
||||
|
||||
|
||||
class TaskStatusMixin(BaseModel):
|
||||
|
|
@ -18,7 +19,23 @@ class TaskStatusMixin(BaseModel):
|
|||
return normalize_task_status(value)
|
||||
|
||||
|
||||
class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
||||
class TaskFlavorMixin(BaseModel):
|
||||
@field_validator("flavor", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalize_flavor(cls, value):
|
||||
flavor = normalize_flavor(value)
|
||||
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
|
||||
raise ValueError(
|
||||
f"Unknown work-record flavor {flavor!r}; "
|
||||
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||
)
|
||||
return flavor
|
||||
|
||||
|
||||
class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin):
|
||||
id: uuid.UUID | None = None
|
||||
record_id: str | None = None
|
||||
flavor: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
|
|
@ -37,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin):
|
|||
return self
|
||||
|
||||
|
||||
class TaskUpdate(TaskStatusMixin):
|
||||
class TaskUpdate(TaskStatusMixin, TaskFlavorMixin):
|
||||
title: str | None = None
|
||||
flavor: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
priority: TaskPriority | None = None
|
||||
|
|
@ -99,6 +117,8 @@ class TaskStatusBulkSync(BaseModel):
|
|||
class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
record_id: str | None = None
|
||||
flavor: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
|
|
|
|||
38
api/schemas/work_record_projection.py
Normal file
38
api/schemas/work_record_projection.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.workplan import WorkplanRead
|
||||
from api.schemas.workplan_dependency import WorkplanDependencyRead
|
||||
|
||||
|
||||
class RepositoryProjectionReconcile(BaseModel):
|
||||
expected_commit: str
|
||||
acknowledge_retirements: bool = False
|
||||
|
||||
@field_validator("expected_commit")
|
||||
@classmethod
|
||||
def validate_commit(cls, value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", value):
|
||||
raise ValueError("expected_commit must be a full 40-character Git SHA")
|
||||
return value
|
||||
|
||||
|
||||
class RepositoryProjectionSnapshot(BaseModel):
|
||||
"""One bounded read of a repository's complete work-record projection."""
|
||||
|
||||
schema_version: Literal["state-hub.repository-projection-snapshot.v1"] = Field(
|
||||
validation_alias="schema",
|
||||
serialization_alias="schema",
|
||||
)
|
||||
repo_slug: str
|
||||
repo_id: uuid.UUID
|
||||
workplans: list[WorkplanRead]
|
||||
tasks: list[TaskRead]
|
||||
dependencies: list[WorkplanDependencyRead]
|
||||
|
|
@ -5,6 +5,12 @@ from typing import Literal
|
|||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from api.schemas.workplan_dependency import WorkplanDepStub
|
||||
from api.work_record_flavor import (
|
||||
FLAVOR_PROMOTION_REASONS,
|
||||
WORK_RECORD_FLAVORS,
|
||||
normalize_flavor,
|
||||
normalize_promotion_reason,
|
||||
)
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
WorkplanStatus = Literal[
|
||||
|
|
@ -28,7 +34,43 @@ class WorkplanStatusMixin(BaseModel):
|
|||
return normalize_workplan_status(value)
|
||||
|
||||
|
||||
class WorkplanCreate(WorkplanStatusMixin):
|
||||
class WorkplanFlavorMixin(BaseModel):
|
||||
@field_validator("flavor", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalise_flavor(cls, value):
|
||||
flavor = normalize_flavor(value)
|
||||
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
|
||||
raise ValueError(
|
||||
f"Unknown work-record flavor {flavor!r}; "
|
||||
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||
)
|
||||
return flavor
|
||||
|
||||
@field_validator("flavor_promotion_reason", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalise_promotion_reason(cls, value):
|
||||
reason = normalize_promotion_reason(value)
|
||||
if reason is not None and reason not in FLAVOR_PROMOTION_REASONS:
|
||||
raise ValueError(
|
||||
f"Unknown flavor_promotion_reason {reason!r}; "
|
||||
f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
|
||||
)
|
||||
return reason
|
||||
|
||||
@field_validator("flavor_promoted_from", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def _normalise_promoted_from(cls, value):
|
||||
flavor = normalize_flavor(value)
|
||||
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
|
||||
raise ValueError(
|
||||
f"Unknown flavor_promoted_from {flavor!r}; "
|
||||
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
|
||||
)
|
||||
return flavor
|
||||
|
||||
|
||||
class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
|
||||
id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
slug: str
|
||||
|
|
@ -39,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin):
|
|||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
flavor: str | None = None
|
||||
flavor_promotion_reason: str | None = None
|
||||
flavor_promoted_from: str | None = None
|
||||
execution_state: ExecutionState = "manual"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
|
|
@ -48,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin):
|
|||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkplanUpdate(WorkplanStatusMixin):
|
||||
class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkplanStatus | None = None
|
||||
|
|
@ -56,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin):
|
|||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
flavor: str | None = None
|
||||
flavor_promotion_reason: str | None = None
|
||||
flavor_promoted_from: str | None = None
|
||||
execution_state: ExecutionState | None = None
|
||||
launch_mode: LaunchMode | None = None
|
||||
concurrency_mode: ConcurrencyMode | None = None
|
||||
|
|
@ -94,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin):
|
|||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
flavor: str | None = None
|
||||
flavor_promotion_reason: str | None = None
|
||||
flavor_promoted_from: str | None = None
|
||||
execution_state: ExecutionState = "manual"
|
||||
launch_mode: LaunchMode = "manual"
|
||||
concurrency_mode: ConcurrencyMode = "sequential"
|
||||
|
|
@ -121,4 +172,4 @@ class WorkplanWithDeps(WorkplanWithTaskCounts):
|
|||
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
|
||||
depends_on: list[WorkplanDepStub] = []
|
||||
blocks: list[WorkplanDepStub] = []
|
||||
blocked_reasons: list[dict] = []
|
||||
blocked_reasons: list[dict] = []
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ EXECUTION_STATES = {
|
|||
"manual": "Not queued for autonomous pickup; humans or agents may still work manually.",
|
||||
"queued": "Candidate for ordered pickup when dependencies and concurrency allow it.",
|
||||
"scheduled": "Waiting for an external launch window; State Hub stores the requested time.",
|
||||
"launching": "A launch request asks for immediate pickup or has been handed off.",
|
||||
"launching": "Legacy state only; it does not prove that any consumer accepted pickup.",
|
||||
"paused": "Temporarily held outside the pickup stack.",
|
||||
"completed": "Execution intent is closed; lifecycle status remains authoritative.",
|
||||
"cancelled": "Execution intent was cancelled without changing lifecycle status.",
|
||||
|
|
@ -19,7 +19,7 @@ LAUNCH_MODES = {
|
|||
"manual": "Do not request automation; keep intent visible only.",
|
||||
"queued": "Place in the prioritized stack for later pickup.",
|
||||
"scheduled": "Request pickup at or after a selected time.",
|
||||
"immediate": "Request prompt activity-core or agent pickup.",
|
||||
"immediate": "Legacy intent only; workplan launch-request pickup is retired.",
|
||||
}
|
||||
|
||||
CONCURRENCY_MODES = {
|
||||
|
|
@ -30,16 +30,26 @@ CONCURRENCY_MODES = {
|
|||
STATE_HUB_RESPONSIBILITIES = [
|
||||
"store lifecycle status separately from execution intent",
|
||||
"rank candidate workplans and expose dependency-aware eligibility",
|
||||
"record launch requests and handoff metadata durably",
|
||||
"surface manual, queued, scheduled, and immediate intent to operators",
|
||||
"preserve historical launch-request and execution-intent rows during retirement",
|
||||
"reject new workplan launch requests because no pickup consumer exists",
|
||||
]
|
||||
|
||||
ACTIVITY_CORE_RESPONSIBILITIES = [
|
||||
"own schedules, wakeups, and recurring automation",
|
||||
"dispatch coding agents and coordinate parallel execution",
|
||||
"acknowledge, run, and complete launch requests when available",
|
||||
"claim and complete ops runs created by ActivityDefinition fires",
|
||||
"do not consume State Hub workplan launch requests",
|
||||
]
|
||||
|
||||
EXECUTION_REPLACEMENTS = {
|
||||
"POST /execution/launch-requests": (
|
||||
"Queue work in the authoritative repository file; for recurring or operational "
|
||||
"fires use an ActivityDefinition and activity-core ops_run"
|
||||
),
|
||||
"GET /execution/launch-requests": "GET /ops-runs on activity-core for automation history",
|
||||
"GET /execution/workplan-stack": "Repo Manager work index or hub-core projection",
|
||||
"PATCH /execution/workplans/{id}/intent": "Edit the authoritative workplan file",
|
||||
}
|
||||
|
||||
EXECUTION_STATE_RANK = {
|
||||
"launching": 0,
|
||||
"queued": 1,
|
||||
|
|
|
|||
|
|
@ -221,6 +221,55 @@ async def ingest_fabric_graph_export(
|
|||
return import_run, True, False
|
||||
|
||||
|
||||
async def activate_fabric_graph_import(
|
||||
session: AsyncSession,
|
||||
import_id: Any,
|
||||
*,
|
||||
requested_by: str,
|
||||
) -> tuple[FabricGraphImport | None, Any | None, bool]:
|
||||
"""Select a retained valid import as the active read model.
|
||||
|
||||
Imports are immutable. Activation only moves the per-source ``is_latest``
|
||||
marker, which makes cutover rollback fast and avoids re-fetching an older
|
||||
authority payload during an incident.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(FabricGraphImport).where(FabricGraphImport.id == import_id)
|
||||
)
|
||||
import_run = result.scalar_one_or_none()
|
||||
if import_run is None:
|
||||
return None, None, False
|
||||
if import_run.validation_status != "valid":
|
||||
raise ValueError("Only a valid Fabric graph import can be activated.")
|
||||
|
||||
previous_result = await session.execute(
|
||||
select(FabricGraphImport).where(
|
||||
FabricGraphImport.source_repo_slug == import_run.source_repo_slug,
|
||||
FabricGraphImport.is_latest.is_(True),
|
||||
)
|
||||
)
|
||||
previous = previous_result.scalars().first()
|
||||
if previous is not None and previous.id == import_run.id:
|
||||
return import_run, previous.id, False
|
||||
|
||||
await _mark_latest(session, import_run)
|
||||
import_run.last_seen_at = datetime.now(timezone.utc)
|
||||
await _record_progress(
|
||||
session,
|
||||
"Fabric graph read model activation changed.",
|
||||
{
|
||||
"source_repo_slug": import_run.source_repo_slug,
|
||||
"import_id": str(import_run.id),
|
||||
"previous_import_id": str(previous.id) if previous else None,
|
||||
"content_hash": import_run.content_hash,
|
||||
"requested_by": requested_by,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(import_run)
|
||||
return import_run, previous.id if previous else None, True
|
||||
|
||||
|
||||
def validate_fabric_graph_export(payload: dict[str, Any]) -> FabricGraphExportPayload:
|
||||
try:
|
||||
export = FabricGraphExportPayload.model_validate(payload)
|
||||
|
|
@ -324,7 +373,10 @@ async def _record_invalid_import(
|
|||
import_run = FabricGraphImport(
|
||||
source_repo_slug=source_repo_slug,
|
||||
source_url=source_url,
|
||||
source_commit=_source_value(payload, "commit"),
|
||||
# Invalid payloads are retained for diagnostics, but their
|
||||
# denormalized provenance must not make the rejection path fail.
|
||||
# The complete input remains available in ``graph_json``.
|
||||
source_commit=_bounded_source_value(payload, "commit", max_length=255),
|
||||
source_path=_source_value(payload, "path"),
|
||||
api_version=str(payload.get("apiVersion")) if payload.get("apiVersion") else None,
|
||||
export_kind=str(payload.get("kind")) if payload.get("kind") else None,
|
||||
|
|
@ -620,6 +672,13 @@ def _source_value(payload: dict[str, Any], field: str) -> str | None:
|
|||
return str(value) if value else None
|
||||
|
||||
|
||||
def _bounded_source_value(
|
||||
payload: dict[str, Any], field: str, *, max_length: int
|
||||
) -> str | None:
|
||||
value = _source_value(payload, field)
|
||||
return value[:max_length] if value is not None else None
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
|
|
|
|||
122
api/services/forge_credential.py
Normal file
122
api/services/forge_credential.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Resolve the forge read credential (STATE-WP-0084-T03, MASON-WP-0003-T05).
|
||||
|
||||
Three sources, tried in order: a mounted file, an environment variable, and
|
||||
OpenBao via Kubernetes auth. Production uses the third — this cluster has no
|
||||
agent injector and no secrets-store CSI driver, so the pod authenticates with
|
||||
its projected ServiceAccount token and reads the KV path itself, which is what
|
||||
`MASON-WP-0003-T02` built the Kubernetes auth role for. The first two exist so
|
||||
the code is runnable and testable outside the cluster.
|
||||
|
||||
**Absent is a supported state, never an error.** A hub with no credential still
|
||||
derives every public repository; only private ones become unreadable, and
|
||||
`STATE-WP-0084-T01` already reports that as its own named condition rather than
|
||||
as records that stopped deriving. So every failure here — no configuration, no
|
||||
network, OpenBao down, permission denied — resolves to `None`. Raising would
|
||||
convert "cannot read nine repositories" into "the whole pass failed".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOKEN_ENV = "FORGE_READ_TOKEN"
|
||||
TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE"
|
||||
OPENBAO_ADDR_ENV = "OPENBAO_ADDR"
|
||||
OPENBAO_ROLE_ENV = "OPENBAO_K8S_ROLE"
|
||||
OPENBAO_JWT_PATH_ENV = "OPENBAO_K8S_TOKEN_PATH"
|
||||
OPENBAO_AUTH_MOUNT_ENV = "OPENBAO_K8S_AUTH_MOUNT"
|
||||
SECRET_PATH_ENV = "FORGE_READ_SECRET_PATH"
|
||||
SECRET_KEY_ENV = "FORGE_READ_SECRET_KEY"
|
||||
|
||||
# Long enough that a fleet reset of 121 repositories does not re-authenticate
|
||||
# 121 times; short enough that a rotated token is picked up without a redeploy,
|
||||
# which is what MASON-WP-0003-T02 requires of this lane.
|
||||
CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
_cache: tuple[float, str | None] | None = None
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def _from_file() -> str | None:
|
||||
path = os.environ.get(TOKEN_FILE_ENV)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
# Deliberately not falling through to the environment: a broken mount
|
||||
# that silently used a stale value would look like success.
|
||||
logger.warning("forge credential: token file %s is unreadable", path)
|
||||
return None
|
||||
|
||||
|
||||
def _from_env() -> str | None:
|
||||
return (os.environ.get(TOKEN_ENV) or "").strip() or None
|
||||
|
||||
|
||||
def _from_openbao() -> str | None:
|
||||
addr = (os.environ.get(OPENBAO_ADDR_ENV) or "").strip().rstrip("/")
|
||||
secret_path = (os.environ.get(SECRET_PATH_ENV) or "").strip().strip("/")
|
||||
role = (os.environ.get(OPENBAO_ROLE_ENV) or "").strip()
|
||||
jwt_path = os.environ.get(OPENBAO_JWT_PATH_ENV) or "/var/run/secrets/openbao/token"
|
||||
mount = (os.environ.get(OPENBAO_AUTH_MOUNT_ENV) or "kubernetes").strip("/")
|
||||
key = (os.environ.get(SECRET_KEY_ENV) or "token").strip()
|
||||
if not (addr and secret_path and role):
|
||||
return None
|
||||
try:
|
||||
jwt = Path(jwt_path).read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
logger.warning("forge credential: no ServiceAccount token at %s", jwt_path)
|
||||
return None
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
login = client.post(
|
||||
f"{addr}/v1/auth/{mount}/login", json={"role": role, "jwt": jwt}
|
||||
)
|
||||
login.raise_for_status()
|
||||
client_token = login.json()["auth"]["client_token"]
|
||||
read = client.get(
|
||||
f"{addr}/v1/{secret_path}", headers={"X-Vault-Token": client_token}
|
||||
)
|
||||
read.raise_for_status()
|
||||
data = read.json()["data"]
|
||||
# KV v2 nests the payload under a second "data"; v1 does not.
|
||||
if isinstance(data.get("data"), dict):
|
||||
data = data["data"]
|
||||
except (httpx.HTTPError, KeyError, ValueError) as exc:
|
||||
# Never include the response body: a failed KV read can echo content.
|
||||
logger.warning("forge credential: OpenBao lookup failed (%s)", type(exc).__name__)
|
||||
return None
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
logger.warning("forge credential: key %r absent at the KV path", key)
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
def forge_read_token(*, use_cache: bool = True) -> str | None:
|
||||
"""The forge read credential, or `None` if this instance has none."""
|
||||
global _cache
|
||||
now = time.monotonic()
|
||||
if use_cache and _cache is not None and now - _cache[0] < CACHE_TTL_SECONDS:
|
||||
return _cache[1]
|
||||
if os.environ.get(TOKEN_FILE_ENV):
|
||||
# Configured to use a file means *that* file and nothing else. Falling
|
||||
# back would let a broken mount quietly resolve to a stale environment
|
||||
# value that nobody knows is in use.
|
||||
token = _from_file()
|
||||
else:
|
||||
token = _from_env() or _from_openbao()
|
||||
_cache = (now, token)
|
||||
return token
|
||||
1499
api/services/forge_projection.py
Normal file
1499
api/services/forge_projection.py
Normal file
File diff suppressed because it is too large
Load diff
215
api/services/forge_repository.py
Normal file
215
api/services/forge_repository.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Narrow Forgejo repository boundary used by repository rename operations.
|
||||
|
||||
The lifecycle service depends on this protocol instead of calling Forgejo
|
||||
directly. Tests replace it with an in-memory gateway, which makes interruption
|
||||
and resume tests deterministic and guarantees that API tests cannot rename a
|
||||
live repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
import httpx
|
||||
|
||||
from api.config import settings
|
||||
from api.services.forge_credential import forge_read_token
|
||||
|
||||
|
||||
WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN"
|
||||
WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE"
|
||||
|
||||
|
||||
class ForgeRepositoryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryUnreadable(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryRedirected(ForgeRepositoryUnreadable):
|
||||
"""Requested coordinate redirects; no credentials are forwarded."""
|
||||
|
||||
def __init__(self, source_url: str, location: str):
|
||||
super().__init__("Forge repository coordinate redirects")
|
||||
self.target_url = urljoin(source_url, location) if location else None
|
||||
|
||||
|
||||
class ForgeRepositoryConflict(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForgeRepositorySnapshot:
|
||||
repository_id: int
|
||||
owner: str
|
||||
name: str
|
||||
full_name: str
|
||||
default_branch: str
|
||||
head_commit: str
|
||||
clone_url: str | None
|
||||
html_url: str | None
|
||||
projection_readable: bool
|
||||
projection_source_present: bool
|
||||
projection_entry_count: int | None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"repository_id": self.repository_id,
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"full_name": self.full_name,
|
||||
"default_branch": self.default_branch,
|
||||
"head_commit": self.head_commit,
|
||||
"clone_url": self.clone_url,
|
||||
"html_url": self.html_url,
|
||||
"projection_readable": self.projection_readable,
|
||||
"projection_source_present": self.projection_source_present,
|
||||
"projection_entry_count": self.projection_entry_count,
|
||||
}
|
||||
|
||||
|
||||
class ForgeRepositoryGateway(Protocol):
|
||||
async def inspect(
|
||||
self, *, instance: str, owner: str, name: str
|
||||
) -> ForgeRepositorySnapshot | None: ...
|
||||
|
||||
async def rename(
|
||||
self, *, instance: str, owner: str, old_name: str, new_name: str
|
||||
) -> ForgeRepositorySnapshot: ...
|
||||
|
||||
|
||||
def _write_token() -> str | None:
|
||||
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
|
||||
if path:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None
|
||||
|
||||
|
||||
class ForgejoRepositoryGateway:
|
||||
"""Forgejo v1 API adapter with separate read and rename credentials."""
|
||||
|
||||
@staticmethod
|
||||
def _headers(token: str | None) -> dict[str, str]:
|
||||
return {"Authorization": f"token {token}"} if token else {}
|
||||
|
||||
@staticmethod
|
||||
def _api(instance: str, owner: str, name: str) -> str:
|
||||
base = instance.rstrip("/")
|
||||
return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
|
||||
|
||||
async def _inspect_with_token(
|
||||
self, *, instance: str, owner: str, name: str, token: str | None
|
||||
) -> ForgeRepositorySnapshot | None:
|
||||
url = self._api(instance, owner, name)
|
||||
timeout = settings.repository_rename_forge_timeout_seconds
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=self._headers(token))
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
raise ForgeRepositoryRedirected(
|
||||
url, response.headers.get("location", "")
|
||||
)
|
||||
if response.status_code == 404:
|
||||
if token is None:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
"Forge returned not-found without an authenticated absence proof"
|
||||
)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
repo = response.json()
|
||||
branch_name = str(repo.get("default_branch") or "")
|
||||
if not branch_name:
|
||||
raise ForgeRepositoryUnreadable("Forge repository has no default branch")
|
||||
branch = await client.get(
|
||||
f"{url}/branches/{quote(branch_name, safe='')}",
|
||||
headers=self._headers(token),
|
||||
)
|
||||
branch.raise_for_status()
|
||||
branch_data = branch.json()
|
||||
commit = branch_data.get("commit") or {}
|
||||
head = str(commit.get("id") or commit.get("sha") or "")
|
||||
if not head:
|
||||
raise ForgeRepositoryUnreadable("Forge default branch has no readable head")
|
||||
projection = await client.get(
|
||||
f"{url}/contents/workplans",
|
||||
params={"ref": head},
|
||||
headers=self._headers(token),
|
||||
)
|
||||
source_present = projection.status_code == 200
|
||||
if projection.status_code not in {200, 404}:
|
||||
projection.raise_for_status()
|
||||
entries = projection.json() if source_present else None
|
||||
except ForgeRepositoryError:
|
||||
raise
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
f"Forge repository inspection failed ({type(exc).__name__})"
|
||||
) from exc
|
||||
return ForgeRepositorySnapshot(
|
||||
repository_id=int(repo["id"]),
|
||||
owner=str((repo.get("owner") or {}).get("login") or owner),
|
||||
name=str(repo["name"]),
|
||||
full_name=str(repo.get("full_name") or f"{owner}/{name}"),
|
||||
default_branch=branch_name,
|
||||
head_commit=head,
|
||||
clone_url=repo.get("clone_url"),
|
||||
html_url=repo.get("html_url"),
|
||||
projection_readable=True,
|
||||
projection_source_present=source_present,
|
||||
projection_entry_count=(len(entries) if isinstance(entries, list) else None),
|
||||
)
|
||||
|
||||
async def inspect(
|
||||
self, *, instance: str, owner: str, name: str
|
||||
) -> ForgeRepositorySnapshot | None:
|
||||
return await self._inspect_with_token(
|
||||
instance=instance, owner=owner, name=name, token=forge_read_token()
|
||||
)
|
||||
|
||||
async def rename(
|
||||
self, *, instance: str, owner: str, old_name: str, new_name: str
|
||||
) -> ForgeRepositorySnapshot:
|
||||
token = _write_token()
|
||||
if not token:
|
||||
raise ForgeRepositoryUnreadable("Forge rename credential is unavailable")
|
||||
url = self._api(instance, owner, old_name)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.repository_rename_forge_timeout_seconds
|
||||
) as client:
|
||||
response = await client.patch(
|
||||
url,
|
||||
json={"name": new_name},
|
||||
headers=self._headers(token),
|
||||
)
|
||||
if response.status_code in {409, 422}:
|
||||
raise ForgeRepositoryConflict("Forge rejected the target repository name")
|
||||
response.raise_for_status()
|
||||
except ForgeRepositoryError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
f"Forge repository rename failed ({type(exc).__name__})"
|
||||
) from exc
|
||||
renamed = await self._inspect_with_token(
|
||||
instance=instance, owner=owner, name=new_name, token=token
|
||||
)
|
||||
if renamed is None:
|
||||
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
|
||||
return renamed
|
||||
|
||||
|
||||
_gateway = ForgejoRepositoryGateway()
|
||||
|
||||
|
||||
def get_forge_repository_gateway() -> ForgeRepositoryGateway:
|
||||
return _gateway
|
||||
|
|
@ -305,7 +305,13 @@ def _summarize_interface(
|
|||
]
|
||||
window = _counters(window_buckets)
|
||||
last_seen = max((bucket.last_seen_at for bucket in buckets), default=None)
|
||||
retirement_candidate, reason = _retirement_state(interface, window.calls)
|
||||
retirement_candidate, reason = _retirement_state(
|
||||
interface,
|
||||
window.calls,
|
||||
all_time_calls=all_time.calls,
|
||||
last_seen_at=last_seen,
|
||||
window_end=window_end,
|
||||
)
|
||||
return LegacyInterfaceSummary(
|
||||
interface=LegacyInterfaceRead.model_validate(interface),
|
||||
all_time=all_time,
|
||||
|
|
@ -340,7 +346,34 @@ def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str)
|
|||
return counts
|
||||
|
||||
|
||||
def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bool, str]:
|
||||
# Minimum quiet period before an interface may be retired, scaled to how much
|
||||
# traffic it ever carried. A review window only proves the interface was silent
|
||||
# for that window; for a high-volume interface that is weak evidence, since a
|
||||
# monthly or quarterly caller can easily fall outside it. Thresholds are
|
||||
# (all-time calls, minimum days since last_seen_at).
|
||||
RETIREMENT_QUIET_LADDER: tuple[tuple[int, int], ...] = (
|
||||
(10_000, 60),
|
||||
(100, 30),
|
||||
(1, 7),
|
||||
)
|
||||
|
||||
|
||||
def _required_quiet_days(all_time_calls: int) -> int:
|
||||
"""Days of silence required before retirement, by all-time call volume."""
|
||||
for threshold, days in RETIREMENT_QUIET_LADDER:
|
||||
if all_time_calls >= threshold:
|
||||
return days
|
||||
return 0
|
||||
|
||||
|
||||
def _retirement_state(
|
||||
interface: LegacyInterface,
|
||||
window_calls: int,
|
||||
*,
|
||||
all_time_calls: int = 0,
|
||||
last_seen_at: datetime | None = None,
|
||||
window_end: datetime | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
if interface.status == "retired":
|
||||
return False, "already retired"
|
||||
if interface.manual_hold:
|
||||
|
|
@ -351,6 +384,19 @@ def _retirement_state(interface: LegacyInterface, window_calls: int) -> tuple[bo
|
|||
return False, "replacement not verified"
|
||||
if window_calls > 0:
|
||||
return False, f"{window_calls} call(s) in review window"
|
||||
|
||||
required_days = _required_quiet_days(all_time_calls)
|
||||
if required_days and last_seen_at is not None and window_end is not None:
|
||||
quiet_days = (window_end - _ensure_datetime(last_seen_at)).days
|
||||
if quiet_days < required_days:
|
||||
return False, (
|
||||
f"quiet {quiet_days}d of {required_days}d required "
|
||||
f"for {all_time_calls} all-time call(s)"
|
||||
)
|
||||
return True, (
|
||||
f"no usage in review window; quiet {quiet_days}d "
|
||||
f"(>= {required_days}d required for {all_time_calls} all-time call(s))"
|
||||
)
|
||||
return True, "no measured usage in review window"
|
||||
|
||||
|
||||
|
|
|
|||
130
api/services/ops_run_projection.py
Normal file
130
api/services/ops_run_projection.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Read-only activity-core ops_run projection (STATE-WP-0078)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from api.config import settings
|
||||
from api.schemas.ops_run import OpsRunProjection, OpsRunProjectionItem
|
||||
|
||||
|
||||
_cache: OpsRunProjection | None = None
|
||||
_cache_at: float = 0.0
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def reset_ops_run_projection_cache() -> None:
|
||||
global _cache, _cache_at
|
||||
_cache = None
|
||||
_cache_at = 0.0
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _fetch_ops_runs() -> dict[str, Any]:
|
||||
if not settings.activity_core_url:
|
||||
raise RuntimeError("ACTIVITY_CORE_URL is not configured")
|
||||
headers = {}
|
||||
if settings.activity_core_worker_token:
|
||||
headers["X-Worker-Token"] = settings.activity_core_worker_token
|
||||
async with httpx.AsyncClient(
|
||||
base_url=settings.activity_core_url.rstrip("/"),
|
||||
timeout=10.0,
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
headers=headers,
|
||||
) as client:
|
||||
response = await client.get("/ops-runs", params={"limit": 200})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _project(payload: dict[str, Any], now: datetime) -> OpsRunProjection:
|
||||
raw_items = payload.get("items") or []
|
||||
counts = payload.get("counts") or {}
|
||||
failed_cutoff = now - timedelta(hours=24)
|
||||
stuck_cutoff = now - timedelta(hours=max(0.1, settings.ops_run_sla_hours))
|
||||
failed_24h = 0
|
||||
stuck = 0
|
||||
items: list[OpsRunProjectionItem] = []
|
||||
|
||||
for raw in raw_items:
|
||||
state = str(raw.get("state") or "unknown")
|
||||
updated_at = _parse_datetime(raw.get("updated_at"))
|
||||
created_at = _parse_datetime(raw.get("created_at"))
|
||||
if state == "failed" and updated_at and updated_at >= failed_cutoff:
|
||||
failed_24h += 1
|
||||
if state in {"open", "claimed"} and (created_at or updated_at):
|
||||
if (created_at or updated_at) < stuck_cutoff:
|
||||
stuck += 1
|
||||
|
||||
result = raw.get("result") if isinstance(raw.get("result"), dict) else {}
|
||||
last_error = result.get("error") or raw.get("last_error")
|
||||
if state in {"open", "claimed", "failed"}:
|
||||
items.append(OpsRunProjectionItem(
|
||||
id=str(raw.get("id")),
|
||||
definition=str(raw.get("activity_definition_id")) if raw.get("activity_definition_id") else None,
|
||||
target_repo=raw.get("target_repo"),
|
||||
state=state,
|
||||
lease={
|
||||
"owner": raw.get("claim_owner"),
|
||||
"until": raw.get("lease_until"),
|
||||
"attempt": raw.get("attempt", 0),
|
||||
} if raw.get("claim_owner") or raw.get("lease_until") else None,
|
||||
updated_at=updated_at,
|
||||
last_error=str(last_error) if last_error else None,
|
||||
))
|
||||
|
||||
items.sort(key=lambda item: item.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
return OpsRunProjection(
|
||||
available=True,
|
||||
generated_at=now,
|
||||
open=int(counts.get("open", sum(item.state == "open" for item in items))),
|
||||
claimed=int(counts.get("claimed", sum(item.state == "claimed" for item in items))),
|
||||
failed_24h=failed_24h,
|
||||
stuck_open_or_claimed=stuck,
|
||||
items=items[:50],
|
||||
)
|
||||
|
||||
|
||||
async def get_ops_run_projection(*, refresh: bool = False) -> OpsRunProjection:
|
||||
global _cache, _cache_at
|
||||
now_mono = time.monotonic()
|
||||
ttl = max(1.0, settings.ops_run_projection_ttl_seconds)
|
||||
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
|
||||
return _cache
|
||||
|
||||
async with _lock:
|
||||
now_mono = time.monotonic()
|
||||
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
|
||||
return _cache
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
projection = _project(await _fetch_ops_runs(), now)
|
||||
except Exception as exc:
|
||||
if _cache is not None and _cache.available:
|
||||
projection = _cache.model_copy(update={
|
||||
"stale": True,
|
||||
"error": f"activity-core refresh failed: {exc}",
|
||||
})
|
||||
else:
|
||||
projection = OpsRunProjection(
|
||||
generated_at=now,
|
||||
error=f"activity-core unavailable: {exc}",
|
||||
)
|
||||
_cache = projection
|
||||
_cache_at = now_mono
|
||||
return projection
|
||||
544
api/services/repo_manager_dual_run.py
Normal file
544
api/services/repo_manager_dual_run.py
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002/0003 Stage B).
|
||||
|
||||
Config file (same as repo-manager): ~/.repo-manager/dual-run.yaml
|
||||
or RM_DUAL_RUN_CONFIG. Env overrides when set:
|
||||
|
||||
RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH
|
||||
|
||||
Rollback: disable in config / unset env → native State Hub path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE = frozenset({"0", "false", "no", "off"})
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml")
|
||||
return Path(raw).expanduser()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_file_config() -> dict[str, Any]:
|
||||
path = config_path()
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
_load_file_config.cache_clear()
|
||||
|
||||
|
||||
def _env_bool(name: str) -> bool | None:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return None
|
||||
v = raw.strip().lower()
|
||||
if v in _TRUE:
|
||||
return True
|
||||
if v in _FALSE:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _cfg_bool(key: str, env_name: str, default: bool = False) -> bool:
|
||||
ev = _env_bool(env_name)
|
||||
if ev is not None:
|
||||
return ev
|
||||
cfg = _load_file_config()
|
||||
if key in cfg:
|
||||
val = cfg[key]
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in _TRUE
|
||||
return default
|
||||
|
||||
|
||||
def writeback_enabled() -> bool:
|
||||
return _cfg_bool("writeback", "RM_WRITEBACK", False)
|
||||
|
||||
|
||||
def reconcile_enabled() -> bool:
|
||||
return _cfg_bool("reconcile", "RM_RECONCILE", False)
|
||||
|
||||
|
||||
def writeback_push_enabled() -> bool:
|
||||
return _cfg_bool("writeback_push", "RM_WRITEBACK_PUSH", False)
|
||||
|
||||
|
||||
def pilot_slugs() -> set[str] | None:
|
||||
raw = os.environ.get("RM_PILOT_REPOS")
|
||||
if raw is not None:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {s.strip() for s in raw.split(",") if s.strip()}
|
||||
cfg = _load_file_config()
|
||||
if "pilot_repos" not in cfg:
|
||||
return None
|
||||
val = cfg["pilot_repos"]
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if not val.strip():
|
||||
return set()
|
||||
return {s.strip() for s in val.split(",") if s.strip()}
|
||||
if isinstance(val, list):
|
||||
return {str(s).strip() for s in val if str(s).strip()}
|
||||
return None
|
||||
|
||||
|
||||
def slug_allowed(slug: str | None) -> bool:
|
||||
if not slug:
|
||||
return False
|
||||
pilots = pilot_slugs()
|
||||
if pilots is None:
|
||||
return True
|
||||
return slug in pilots
|
||||
|
||||
|
||||
def writeback_for_repo(slug: str | None) -> bool:
|
||||
return writeback_enabled() and slug_allowed(slug)
|
||||
|
||||
|
||||
def reconcile_for_repo(slug: str | None) -> bool:
|
||||
return reconcile_enabled() and slug_allowed(slug)
|
||||
|
||||
|
||||
def meter_path() -> Path:
|
||||
raw = os.environ.get("RM_METER_PATH")
|
||||
if raw:
|
||||
return Path(raw).expanduser()
|
||||
cfg = _load_file_config()
|
||||
if cfg.get("meter_path"):
|
||||
return Path(str(cfg["meter_path"])).expanduser()
|
||||
return Path("~/.repo-manager/mutation-meter.jsonl").expanduser()
|
||||
|
||||
|
||||
def record_mutation(
|
||||
*,
|
||||
source: str,
|
||||
kind: str,
|
||||
repo_slug: str | None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
path = meter_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"repo_slug": repo_slug,
|
||||
"detail": detail or {},
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(row, default=str) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run_rmgr(args: list[str], *, timeout: int = 120) -> tuple[int, str, str]:
|
||||
env = os.environ.copy()
|
||||
custom = os.environ.get("RMGR_BIN", "").strip()
|
||||
if custom:
|
||||
cmd = custom.split() + args
|
||||
else:
|
||||
src = os.environ.get("REPO_MANAGER_SRC", "").strip()
|
||||
sibling = Path(src) if src else (Path.home() / "repo-manager" / "src")
|
||||
if not sibling.is_dir() and (Path.home() / "repo-manager" / "src").is_dir():
|
||||
sibling = Path.home() / "repo-manager" / "src"
|
||||
if sibling.is_dir():
|
||||
env["PYTHONPATH"] = str(sibling) + (
|
||||
os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else ""
|
||||
)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from repo_manager.cli import main; import sys; raise SystemExit(main(sys.argv[1:]))",
|
||||
*args,
|
||||
]
|
||||
else:
|
||||
cmd = ["rmgr", *args]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def rm_update_task_status(
|
||||
*,
|
||||
repo_path: str | Path,
|
||||
task_id: str,
|
||||
status: str,
|
||||
repo_slug: str | None = None,
|
||||
reason: str = "state-hub dual-run",
|
||||
push: bool | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
if push is None:
|
||||
push = writeback_push_enabled()
|
||||
args = [
|
||||
"update-task-status",
|
||||
"--path",
|
||||
str(repo_path),
|
||||
"--task-id",
|
||||
str(task_id),
|
||||
"--status",
|
||||
status,
|
||||
"--reason",
|
||||
reason,
|
||||
"--correlation-id",
|
||||
correlation_id,
|
||||
"--idempotency-key",
|
||||
f"sh-dual-{task_id}-{status}-{correlation_id}",
|
||||
]
|
||||
if repo_slug:
|
||||
args.extend(["--slug", repo_slug])
|
||||
if push:
|
||||
args.append("--push")
|
||||
|
||||
code, out, err = run_rmgr(args)
|
||||
try:
|
||||
result = json.loads(out.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result = {
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "internal",
|
||||
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
|
||||
},
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
if code != 0 and result.get("status") not in ("applied", "rejected"):
|
||||
result.setdefault("status", "failed")
|
||||
result.setdefault(
|
||||
"error",
|
||||
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def rm_update_workplan(
|
||||
*,
|
||||
repo_path: str | Path,
|
||||
workplan_id: str,
|
||||
operation: str = "update",
|
||||
title: str | None = None,
|
||||
goal: str | None = None,
|
||||
status: str | None = None,
|
||||
owner: str | None = None,
|
||||
domain: str | None = None,
|
||||
topic_slug: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
reason: str = "state-hub dual-run",
|
||||
push: bool | None = None,
|
||||
correlation_id: str | None = None,
|
||||
confirm_archive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate a file-backed workplan mutation to Repo Manager.
|
||||
|
||||
``operation=archive`` is the recoverable counterpart of State Hub's
|
||||
DELETE workplan route; it never erases repository history.
|
||||
"""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
if operation not in {"create", "update", "archive"}:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"correlation_id": correlation_id,
|
||||
"error": {"code": "validation_error", "message": f"invalid operation {operation!r}"},
|
||||
}
|
||||
if push is None:
|
||||
push = writeback_push_enabled()
|
||||
cli_operation = "delete" if operation == "archive" else operation
|
||||
args = [
|
||||
"workplan",
|
||||
cli_operation,
|
||||
"--path",
|
||||
str(repo_path),
|
||||
"--workplan-id",
|
||||
str(workplan_id),
|
||||
"--reason",
|
||||
reason,
|
||||
"--correlation-id",
|
||||
correlation_id,
|
||||
"--idempotency-key",
|
||||
f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}",
|
||||
]
|
||||
for flag, value in (
|
||||
("--title", title),
|
||||
("--goal", goal),
|
||||
("--status", status),
|
||||
("--owner", owner),
|
||||
("--domain", domain),
|
||||
("--topic-slug", topic_slug),
|
||||
("--slug", repo_slug),
|
||||
):
|
||||
if value is not None:
|
||||
args.extend([flag, value])
|
||||
if operation == "archive" and confirm_archive:
|
||||
args.append("--confirm")
|
||||
if push:
|
||||
args.append("--push")
|
||||
|
||||
code, out, err = run_rmgr(args)
|
||||
try:
|
||||
result = json.loads(out.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result = {
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "internal",
|
||||
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
|
||||
},
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
if code != 0 and result.get("status") not in ("applied", "rejected"):
|
||||
result.setdefault("status", "failed")
|
||||
result.setdefault(
|
||||
"error",
|
||||
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def rm_update_register_entry(
|
||||
*,
|
||||
repo_path: str | Path,
|
||||
kind: str,
|
||||
entry_id: str,
|
||||
operation: str = "put",
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
note: str | None = None,
|
||||
author: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
reason: str = "state-hub retirement adapter",
|
||||
push: bool | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate register create/update/defer/note to Repo Manager."""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
if operation not in {"put", "defer", "note"}:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"correlation_id": correlation_id,
|
||||
"error": {"code": "validation_error", "message": f"invalid operation {operation!r}"},
|
||||
}
|
||||
if push is None:
|
||||
push = writeback_push_enabled()
|
||||
args = [
|
||||
"register",
|
||||
operation,
|
||||
"--path",
|
||||
str(repo_path),
|
||||
"--kind",
|
||||
kind,
|
||||
"--entry-id",
|
||||
entry_id,
|
||||
"--reason",
|
||||
reason,
|
||||
"--correlation-id",
|
||||
correlation_id,
|
||||
"--idempotency-key",
|
||||
f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}",
|
||||
]
|
||||
for flag, value in (
|
||||
("--title", title),
|
||||
("--status", status),
|
||||
("--note", note),
|
||||
("--author", author),
|
||||
("--slug", repo_slug),
|
||||
):
|
||||
if value is not None:
|
||||
args.extend([flag, value])
|
||||
if data is not None:
|
||||
args.extend(["--data-json", json.dumps(data, separators=(",", ":"))])
|
||||
if push:
|
||||
args.append("--push")
|
||||
|
||||
code, out, err = run_rmgr(args)
|
||||
try:
|
||||
result = json.loads(out.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result = {
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "internal",
|
||||
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
|
||||
},
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
if code != 0 and result.get("status") not in ("applied", "rejected"):
|
||||
result.setdefault("status", "failed")
|
||||
result.setdefault(
|
||||
"error",
|
||||
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def rm_scan_sbom(
|
||||
*,
|
||||
repo_path: str | Path,
|
||||
repo_slug: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Derive a versioned SBOM snapshot from repository-owned sources."""
|
||||
args = ["sbom", "scan", "--path", str(repo_path)]
|
||||
if repo_slug:
|
||||
args.extend(["--slug", repo_slug])
|
||||
|
||||
code, out, err = run_rmgr(args)
|
||||
try:
|
||||
result = json.loads(out.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
|
||||
}
|
||||
if code != 0:
|
||||
result.setdefault("ok", False)
|
||||
result.setdefault("error", f"rmgr exit={code} stderr={err!r}")
|
||||
result["exit_code"] = code
|
||||
return result
|
||||
|
||||
|
||||
def rm_scaffold(
|
||||
*,
|
||||
repo_path: str | Path,
|
||||
flavor: str,
|
||||
wp_prefix: str | None = None,
|
||||
slug: str | None = None,
|
||||
domain: str = "infotech",
|
||||
force: bool = False,
|
||||
commit: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate repository scaffolding to ``rmgr scaffold`` (STATE-WP-0080-T02)."""
|
||||
args = [
|
||||
"scaffold",
|
||||
"--path",
|
||||
str(repo_path),
|
||||
"--flavor",
|
||||
flavor,
|
||||
"--domain",
|
||||
domain,
|
||||
]
|
||||
if slug:
|
||||
args.extend(["--slug", slug])
|
||||
if wp_prefix:
|
||||
args.extend(["--wp-prefix", wp_prefix])
|
||||
if force:
|
||||
args.append("--force")
|
||||
if not commit:
|
||||
args.append("--no-commit")
|
||||
code, out, err = run_rmgr(args)
|
||||
try:
|
||||
result = json.loads(out.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result = {
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "internal",
|
||||
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
|
||||
},
|
||||
}
|
||||
if code != 0 and result.get("status") not in ("applied", "rejected"):
|
||||
result.setdefault("status", "failed")
|
||||
result.setdefault(
|
||||
"error",
|
||||
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
|
||||
)
|
||||
result["exit_code"] = code
|
||||
return result
|
||||
|
||||
|
||||
def rm_reconcile(*, repo_path: str | Path, repo_slug: str | None = None) -> dict[str, Any]:
|
||||
args = ["reconcile", "--path", str(repo_path)]
|
||||
if repo_slug:
|
||||
args.extend(["--slug", repo_slug])
|
||||
code, out, err = run_rmgr(args)
|
||||
try:
|
||||
result = json.loads(out.strip() or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result = {"ok": False, "error": err or out, "exit_code": code}
|
||||
result["exit_code"] = code
|
||||
return result
|
||||
|
||||
|
||||
def try_writeback_for_task(
|
||||
*,
|
||||
repo_path: str | Path | None,
|
||||
repo_slug: str | None,
|
||||
task_id: str,
|
||||
status: str,
|
||||
reason: str = "state-hub dual-run PATCH /tasks",
|
||||
) -> dict[str, Any] | None:
|
||||
if not writeback_for_repo(repo_slug):
|
||||
return None
|
||||
if not repo_path:
|
||||
logger.warning("RM dual-run writeback skipped: no repo_path for %s", repo_slug)
|
||||
return None
|
||||
path = Path(repo_path)
|
||||
if not path.is_dir():
|
||||
logger.warning("RM dual-run writeback skipped: missing path %s", path)
|
||||
return None
|
||||
|
||||
result = rm_update_task_status(
|
||||
repo_path=path,
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
repo_slug=repo_slug,
|
||||
reason=reason,
|
||||
push=writeback_push_enabled(),
|
||||
)
|
||||
if result.get("status") == "applied":
|
||||
record_mutation(
|
||||
source="repo-manager",
|
||||
kind="task_status_writeback_via_sh_facade",
|
||||
repo_slug=repo_slug,
|
||||
detail={
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"git_sha": (result.get("evidence") or {}).get("git_sha"),
|
||||
"push_ok": (result.get("evidence") or {}).get("push_ok"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"RM dual-run writeback failed for %s task=%s: %s",
|
||||
repo_slug,
|
||||
task_id,
|
||||
result.get("error") or result,
|
||||
)
|
||||
return result
|
||||
229
api/services/repository_aliases.py
Normal file
229
api/services/repository_aliases.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Canonical repository identity resolution across current and prior slugs.
|
||||
|
||||
The slug registry is the lookup boundary. Historical records deliberately keep
|
||||
the slug they recorded; callers use ``slug_values`` when they need an identity-
|
||||
wide read and ``canonical_slug`` when they create a new reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repository_rename import RepositorySlug
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepositorySlugResolution:
|
||||
repo: ManagedRepo
|
||||
requested_slug: str
|
||||
canonical_slug: str
|
||||
slug_status: str
|
||||
aliases: tuple[str, ...]
|
||||
source_operation_id: uuid.UUID | None = None
|
||||
|
||||
@property
|
||||
def slug_values(self) -> tuple[str, ...]:
|
||||
return (self.canonical_slug, *self.aliases)
|
||||
|
||||
|
||||
async def resolve_repository_slug(
|
||||
session: AsyncSession,
|
||||
slug: str,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> RepositorySlugResolution | None:
|
||||
"""Resolve a current or protected prior slug to one repository UUID."""
|
||||
|
||||
record = (
|
||||
await session.execute(
|
||||
select(RepositorySlug).where(RepositorySlug.slug == slug)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
# Compatibility for databases upgraded before the identity backfill has run.
|
||||
if record is None:
|
||||
repo = (
|
||||
await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
).scalar_one_or_none()
|
||||
if repo is None:
|
||||
if required:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return None
|
||||
return RepositorySlugResolution(
|
||||
repo=repo,
|
||||
requested_slug=slug,
|
||||
canonical_slug=repo.slug,
|
||||
slug_status="canonical",
|
||||
aliases=(),
|
||||
)
|
||||
|
||||
repo = await session.get(ManagedRepo, record.repo_id)
|
||||
if repo is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Repository slug registry entry '{slug}' has no repository",
|
||||
)
|
||||
records = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(RepositorySlug)
|
||||
.where(RepositorySlug.repo_id == repo.id)
|
||||
.order_by(RepositorySlug.kind.desc(), RepositorySlug.slug)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
canonicals = [item for item in records if item.kind == "canonical"]
|
||||
if len(canonicals) != 1 or canonicals[0].slug != repo.slug:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Repository '{repo.id}' has inconsistent canonical slug state",
|
||||
)
|
||||
return RepositorySlugResolution(
|
||||
repo=repo,
|
||||
requested_slug=slug,
|
||||
canonical_slug=repo.slug,
|
||||
slug_status=record.kind,
|
||||
aliases=tuple(item.slug for item in records if item.kind == "alias"),
|
||||
source_operation_id=record.source_operation_id,
|
||||
)
|
||||
|
||||
|
||||
async def repository_resolutions_for_repositories(
|
||||
session: AsyncSession,
|
||||
repositories: list[ManagedRepo],
|
||||
) -> dict[uuid.UUID, RepositorySlugResolution]:
|
||||
"""Resolve a repository collection with one slug-registry query.
|
||||
|
||||
Collection callers already hold canonical ``ManagedRepo`` rows. Resolving
|
||||
every row through :func:`resolve_repository_slug` would issue two identity
|
||||
queries per repository. Load the complete alias sets once instead while
|
||||
retaining the pre-registry compatibility behavior.
|
||||
"""
|
||||
|
||||
if not repositories:
|
||||
return {}
|
||||
|
||||
repository_ids = [repository.id for repository in repositories]
|
||||
records = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(RepositorySlug)
|
||||
.options(
|
||||
noload(RepositorySlug.repo),
|
||||
noload(RepositorySlug.source_operation),
|
||||
)
|
||||
.where(RepositorySlug.repo_id.in_(repository_ids))
|
||||
.order_by(RepositorySlug.repo_id, RepositorySlug.kind.desc(), RepositorySlug.slug)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
records_by_repository: dict[uuid.UUID, list[RepositorySlug]] = {
|
||||
repository_id: [] for repository_id in repository_ids
|
||||
}
|
||||
for record in records:
|
||||
records_by_repository[record.repo_id].append(record)
|
||||
|
||||
resolutions: dict[uuid.UUID, RepositorySlugResolution] = {}
|
||||
for repository in repositories:
|
||||
repository_records = records_by_repository[repository.id]
|
||||
requested_record = next(
|
||||
(record for record in repository_records if record.slug == repository.slug),
|
||||
None,
|
||||
)
|
||||
if requested_record is None:
|
||||
# Compatibility for databases upgraded before the identity backfill.
|
||||
resolutions[repository.id] = RepositorySlugResolution(
|
||||
repo=repository,
|
||||
requested_slug=repository.slug,
|
||||
canonical_slug=repository.slug,
|
||||
slug_status="canonical",
|
||||
aliases=(),
|
||||
)
|
||||
continue
|
||||
|
||||
canonicals = [record for record in repository_records if record.kind == "canonical"]
|
||||
if len(canonicals) != 1 or canonicals[0].slug != repository.slug:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Repository '{repository.id}' has inconsistent canonical slug state",
|
||||
)
|
||||
resolutions[repository.id] = RepositorySlugResolution(
|
||||
repo=repository,
|
||||
requested_slug=repository.slug,
|
||||
canonical_slug=repository.slug,
|
||||
slug_status=requested_record.kind,
|
||||
aliases=tuple(
|
||||
record.slug for record in repository_records if record.kind == "alias"
|
||||
),
|
||||
source_operation_id=requested_record.source_operation_id,
|
||||
)
|
||||
|
||||
return resolutions
|
||||
|
||||
|
||||
async def canonicalize_repository_slug(session: AsyncSession, value: str) -> str:
|
||||
"""Canonicalize a value only when it is a registered repository identity."""
|
||||
|
||||
resolution = await resolve_repository_slug(session, value, required=False)
|
||||
return resolution.canonical_slug if resolution is not None else value
|
||||
|
||||
|
||||
async def repository_resolution_for_id(
|
||||
session: AsyncSession, repo: ManagedRepo
|
||||
) -> RepositorySlugResolution:
|
||||
return await resolve_repository_slug(session, repo.slug) # type: ignore[return-value]
|
||||
|
||||
|
||||
async def stale_external_references(
|
||||
session: AsyncSession,
|
||||
resolution: RepositorySlugResolution,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Name external projections that still contain an historical slug.
|
||||
|
||||
These are handoffs to the projection owner, never implicit rewrite targets.
|
||||
"""
|
||||
|
||||
if not resolution.aliases:
|
||||
return []
|
||||
aliases = list(resolution.aliases)
|
||||
checks = (
|
||||
(FabricGraphImport, "source_repo_slug"),
|
||||
(FabricGraphNode, "source_repo_slug"),
|
||||
(FabricGraphNode, "repo_slug"),
|
||||
(FabricGraphEdge, "source_repo_slug"),
|
||||
)
|
||||
stale: list[dict[str, object]] = []
|
||||
for model, field_name in checks:
|
||||
field = getattr(model, field_name)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(field, func.count()).where(field.in_(aliases)).group_by(field)
|
||||
)
|
||||
).all()
|
||||
for value, count in rows:
|
||||
stale.append(
|
||||
{
|
||||
"owner": "railiance-fabric",
|
||||
"surface": model.__tablename__,
|
||||
"field": field_name,
|
||||
"value": value,
|
||||
"count": count,
|
||||
"status": "stale",
|
||||
"handoff": "owner update or re-ingest required",
|
||||
}
|
||||
)
|
||||
return stale
|
||||
|
||||
|
||||
def affected_slug_predicate(field: object, slugs: tuple[str, ...]):
|
||||
"""Match a JSONB slug-list against any name in one repository lineage."""
|
||||
|
||||
return or_(*(field.contains([slug]) for slug in slugs))
|
||||
23
api/services/repository_identity.py
Normal file
23
api/services/repository_identity.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repository_rename import RepositoryForgeIdentity, RepositorySlug
|
||||
|
||||
|
||||
def stage_initial_repository_identity(
|
||||
session: AsyncSession, repo: ManagedRepo
|
||||
) -> None:
|
||||
"""Attach non-guessed identity state to a newly flushed repository row."""
|
||||
if repo.id is None:
|
||||
raise ValueError("repository must be flushed before identity records are staged")
|
||||
session.add_all(
|
||||
[
|
||||
RepositoryForgeIdentity(repo_id=repo.id),
|
||||
RepositorySlug(
|
||||
repo_id=repo.id,
|
||||
slug=repo.slug,
|
||||
kind="canonical",
|
||||
protected=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
1784
api/services/repository_rename.py
Normal file
1784
api/services/repository_rename.py
Normal file
File diff suppressed because it is too large
Load diff
336
api/services/review_contracts.py
Normal file
336
api/services/review_contracts.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||
from api.schemas.review_contract import (
|
||||
GateReviewState,
|
||||
OwnerReviewState,
|
||||
ReviewAggregateRead,
|
||||
)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode()
|
||||
|
||||
|
||||
def digest_document(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def _assertion_id(text: str) -> str:
|
||||
return "assertion:" + hashlib.sha256(text.encode()).hexdigest()[:20]
|
||||
|
||||
|
||||
def normalize_contract(raw: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Validate v1 or adapt the WP-0024 prototype without executing its checks."""
|
||||
digest = digest_document(raw)
|
||||
if raw.get("interface") == "railiance.owner-review" and raw.get("version") == 1:
|
||||
owners_raw = raw.get("owners")
|
||||
task_owners = raw.get("task_owners")
|
||||
hashes = raw.get("artifact_sha256")
|
||||
if (
|
||||
not isinstance(owners_raw, dict)
|
||||
or not owners_raw
|
||||
or not isinstance(task_owners, dict)
|
||||
or not task_owners
|
||||
):
|
||||
raise HTTPException(
|
||||
422, "legacy contract requires non-empty owners and task_owners"
|
||||
)
|
||||
if not isinstance(hashes, dict):
|
||||
raise HTTPException(422, "legacy contract requires artifact_sha256")
|
||||
owners = []
|
||||
for owner_id, scope in owners_raw.items():
|
||||
artifacts = scope.get("artifacts") or []
|
||||
assertions = scope.get("assertions") or []
|
||||
checks = scope.get("checks") or []
|
||||
if (
|
||||
not artifacts
|
||||
or not assertions
|
||||
or not checks
|
||||
or any(path not in hashes for path in artifacts)
|
||||
):
|
||||
raise HTTPException(422, f"{owner_id}: incomplete legacy owner scope")
|
||||
owners.append(
|
||||
{
|
||||
"id": owner_id,
|
||||
"artifact_ids": artifacts,
|
||||
"assertions": [
|
||||
{"id": _assertion_id(text), "statement": text}
|
||||
for text in assertions
|
||||
],
|
||||
"check_ids": checks,
|
||||
}
|
||||
)
|
||||
owner_ids = {item["id"] for item in owners}
|
||||
gates = []
|
||||
for gate_id, required in task_owners.items():
|
||||
if (
|
||||
not required
|
||||
or len(required) != len(set(required))
|
||||
or not set(required) <= owner_ids
|
||||
):
|
||||
raise HTTPException(422, f"{gate_id}: invalid legacy owner set")
|
||||
gates.append({"id": gate_id, "policy": "all_required", "owners": required})
|
||||
normalized = {
|
||||
"schema_version": "review-contract/v1",
|
||||
"contract_key": raw.get("workplan_id"),
|
||||
"subject": {"kind": "workplan", "id": raw.get("workplan_id")},
|
||||
"owners": owners,
|
||||
"gates": gates,
|
||||
"artifacts": {
|
||||
path: {"algorithm": "sha256", "digest": value}
|
||||
for path, value in hashes.items()
|
||||
},
|
||||
"allowed_dispositions": [
|
||||
item.replace("-", "_") for item in (raw.get("decisions") or [])
|
||||
],
|
||||
"legacy": {
|
||||
"interface": raw["interface"],
|
||||
"version": 1,
|
||||
"source_digest": digest,
|
||||
},
|
||||
}
|
||||
return validate_v1(normalized), digest
|
||||
if raw.get("schema_version") != "review-contract/v1":
|
||||
raise HTTPException(422, "schema_version must be review-contract/v1")
|
||||
return validate_v1(raw), digest
|
||||
|
||||
|
||||
def validate_v1(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
contract_key, subject = doc.get("contract_key"), doc.get("subject")
|
||||
owners, gates, artifacts = doc.get("owners"), doc.get("gates"), doc.get("artifacts")
|
||||
dispositions = doc.get("allowed_dispositions")
|
||||
if not isinstance(contract_key, str) or not contract_key:
|
||||
raise HTTPException(422, "contract_key is required")
|
||||
if (
|
||||
not isinstance(subject, dict)
|
||||
or not subject.get("kind")
|
||||
or not subject.get("id")
|
||||
):
|
||||
raise HTTPException(422, "typed subject kind and id are required")
|
||||
if (
|
||||
not isinstance(owners, list)
|
||||
or not owners
|
||||
or not isinstance(gates, list)
|
||||
or not gates
|
||||
):
|
||||
raise HTTPException(422, "non-empty owners and gates are required")
|
||||
if not isinstance(artifacts, dict) or not artifacts:
|
||||
raise HTTPException(422, "revision-pinned artifacts are required")
|
||||
if (
|
||||
set(dispositions or []) != {"approve", "request_changes"}
|
||||
or len(dispositions) != 2
|
||||
):
|
||||
raise HTTPException(
|
||||
422, "allowed_dispositions must contain approve and request_changes only"
|
||||
)
|
||||
owner_ids: set[str] = set()
|
||||
for owner in owners:
|
||||
if not isinstance(owner, dict):
|
||||
raise HTTPException(422, "owner entries must be objects")
|
||||
owner_id = owner.get("id")
|
||||
artifact_ids = owner.get("artifact_ids") or []
|
||||
assertions = owner.get("assertions") or []
|
||||
checks = owner.get("check_ids") or []
|
||||
assertion_ids = [
|
||||
item.get("id") for item in assertions if isinstance(item, dict)
|
||||
]
|
||||
if (
|
||||
not owner_id
|
||||
or owner_id in owner_ids
|
||||
or not artifact_ids
|
||||
or not checks
|
||||
or not assertion_ids
|
||||
):
|
||||
raise HTTPException(
|
||||
422,
|
||||
"each owner needs a unique id and non-empty artifact/assertion/check scope",
|
||||
)
|
||||
if len(assertion_ids) != len(set(assertion_ids)) or any(
|
||||
path not in artifacts for path in artifact_ids
|
||||
):
|
||||
raise HTTPException(422, f"{owner_id}: invalid assertion or artifact scope")
|
||||
owner_ids.add(owner_id)
|
||||
for artifact_id, artifact in artifacts.items():
|
||||
if (
|
||||
not isinstance(artifact, dict)
|
||||
or artifact.get("algorithm") != "sha256"
|
||||
or not _is_sha256(artifact.get("digest"))
|
||||
):
|
||||
raise HTTPException(
|
||||
422, f"{artifact_id}: only sha256 artifacts are supported"
|
||||
)
|
||||
gate_ids: set[str] = set()
|
||||
for gate in gates:
|
||||
if not isinstance(gate, dict):
|
||||
raise HTTPException(422, "gate entries must be objects")
|
||||
required = gate.get("owners") or []
|
||||
if (
|
||||
gate.get("policy") != "all_required"
|
||||
or not gate.get("id")
|
||||
or gate["id"] in gate_ids
|
||||
):
|
||||
raise HTTPException(
|
||||
422, "v1 gates require unique ids and all_required policy"
|
||||
)
|
||||
if (
|
||||
not required
|
||||
or len(required) != len(set(required))
|
||||
or not set(required) <= owner_ids
|
||||
):
|
||||
raise HTTPException(422, f"{gate.get('id')}: gate owner set is invalid")
|
||||
gate_ids.add(gate["id"])
|
||||
return doc
|
||||
|
||||
|
||||
def _is_sha256(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 64
|
||||
and all(c in "0123456789abcdef" for c in value)
|
||||
)
|
||||
|
||||
|
||||
def owner_scope(contract: ReviewContract, owner_id: str) -> dict[str, Any]:
|
||||
for owner in contract.document["owners"]:
|
||||
if owner["id"] == owner_id:
|
||||
return owner
|
||||
raise HTTPException(422, f"unknown owner: {owner_id}")
|
||||
|
||||
|
||||
def validate_receipt(contract: ReviewContract, body: Any) -> dict[str, Any]:
|
||||
if body.contract_digest != contract.contract_digest:
|
||||
raise HTTPException(
|
||||
409, "contract digest mismatch; re-review the active contract"
|
||||
)
|
||||
scope = owner_scope(contract, body.owner_id)
|
||||
if body.disposition == "request_changes":
|
||||
if not body.note or not body.note.strip():
|
||||
raise HTTPException(422, "request_changes requires a note")
|
||||
else:
|
||||
expected_artifacts = {
|
||||
key: contract.document["artifacts"][key]["digest"]
|
||||
for key in scope["artifact_ids"]
|
||||
}
|
||||
if body.artifact_hashes != expected_artifacts:
|
||||
raise HTTPException(
|
||||
422, "approval artifact hashes do not exactly match the owner scope"
|
||||
)
|
||||
expected_assertions = {item["id"] for item in scope["assertions"]}
|
||||
if set(body.assertion_ids) != expected_assertions or len(
|
||||
body.assertion_ids
|
||||
) != len(expected_assertions):
|
||||
raise HTTPException(
|
||||
422, "approval assertions do not exactly match the owner scope"
|
||||
)
|
||||
expected_checks = set(scope["check_ids"])
|
||||
actual_checks = {
|
||||
item.get("id") for item in body.checks if isinstance(item, dict)
|
||||
}
|
||||
if actual_checks != expected_checks or len(body.checks) != len(expected_checks):
|
||||
raise HTTPException(
|
||||
422, "approval checks do not exactly match the owner scope"
|
||||
)
|
||||
if any(
|
||||
item.get("passed") is not True or item.get("read_only") is not True
|
||||
for item in body.checks
|
||||
):
|
||||
raise HTTPException(
|
||||
422, "every approval check must be passed and explicitly read_only"
|
||||
)
|
||||
return {
|
||||
"owner_id": body.owner_id,
|
||||
"actor": body.actor,
|
||||
"disposition": body.disposition,
|
||||
"contract_digest": body.contract_digest,
|
||||
"artifact_hashes": body.artifact_hashes,
|
||||
"assertion_ids": body.assertion_ids,
|
||||
"checks": body.checks,
|
||||
"note": body.note,
|
||||
"source": body.source.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
async def active_contract(session: AsyncSession, contract_key: str) -> ReviewContract:
|
||||
result = await session.execute(
|
||||
select(ReviewContract).where(
|
||||
ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True)
|
||||
)
|
||||
)
|
||||
contract = result.scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise HTTPException(404, f"active review contract not found: {contract_key}")
|
||||
return contract
|
||||
|
||||
|
||||
async def aggregate(
|
||||
session: AsyncSession, contract: ReviewContract
|
||||
) -> ReviewAggregateRead:
|
||||
rows = await session.execute(
|
||||
select(ReviewReceipt)
|
||||
.where(ReviewReceipt.contract_id == contract.id)
|
||||
.order_by(ReviewReceipt.submitted_at.desc(), ReviewReceipt.id.desc())
|
||||
)
|
||||
latest: dict[str, ReviewReceipt] = {}
|
||||
for receipt in rows.scalars():
|
||||
latest.setdefault(receipt.owner_id, receipt)
|
||||
prior = await session.execute(
|
||||
select(ReviewReceipt.owner_id)
|
||||
.join(ReviewContract)
|
||||
.where(
|
||||
ReviewContract.contract_key == contract.contract_key,
|
||||
ReviewContract.id != contract.id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
stale_owners = set(prior.scalars())
|
||||
owner_states, statuses = [], {}
|
||||
for owner in contract.document["owners"]:
|
||||
owner_id, receipt = owner["id"], latest.get(owner["id"])
|
||||
state = (
|
||||
receipt.disposition.replace("approve", "approved")
|
||||
if receipt
|
||||
else ("stale" if owner_id in stale_owners else "missing")
|
||||
)
|
||||
statuses[owner_id] = state
|
||||
owner_states.append(
|
||||
OwnerReviewState(
|
||||
owner_id=owner_id,
|
||||
status=state,
|
||||
receipt_id=receipt.id if receipt else None,
|
||||
receipt_digest=receipt.receipt_digest if receipt else None,
|
||||
submitted_at=receipt.submitted_at if receipt else None,
|
||||
)
|
||||
)
|
||||
gates = [
|
||||
GateReviewState(
|
||||
gate_id=gate["id"],
|
||||
policy="all_required",
|
||||
owners=gate["owners"],
|
||||
satisfied=all(statuses[owner] == "approved" for owner in gate["owners"]),
|
||||
)
|
||||
for gate in contract.document["gates"]
|
||||
]
|
||||
return ReviewAggregateRead(
|
||||
contract_key=contract.contract_key,
|
||||
contract_digest=contract.contract_digest,
|
||||
satisfied=all(gate.satisfied for gate in gates),
|
||||
authorizes_execution=False,
|
||||
owners=owner_states,
|
||||
gates=gates,
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
70
api/services/sbom_nexus.py
Normal file
70
api/services/sbom_nexus.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Read-only client for the reversible SBOM Nexus compatibility facade."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from api.config import settings
|
||||
|
||||
|
||||
class SBOMNexusError(RuntimeError):
|
||||
"""An SBOM Nexus request failed without a usable compatibility response."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def reads_from_nexus() -> bool:
|
||||
return settings.sbom_nexus_read_mode == "nexus"
|
||||
|
||||
|
||||
def writes_to_nexus() -> bool:
|
||||
return settings.sbom_nexus_write_mode == "nexus"
|
||||
|
||||
|
||||
async def request_json(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Call Nexus; never fall back silently to legacy storage."""
|
||||
if not settings.sbom_nexus_url:
|
||||
raise SBOMNexusError(503, "SBOM Nexus read mode is enabled without SBOM_NEXUS_URL")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=settings.sbom_nexus_url.rstrip("/"),
|
||||
timeout=settings.sbom_nexus_timeout_seconds,
|
||||
) as client:
|
||||
response = await client.request(method, path, params=params, json=body)
|
||||
except httpx.RequestError as exc:
|
||||
raise SBOMNexusError(502, f"SBOM Nexus is unavailable: {exc.__class__.__name__}") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
detail = f"SBOM Nexus returned HTTP {response.status_code}"
|
||||
try:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
|
||||
detail = payload["detail"]
|
||||
except ValueError:
|
||||
pass
|
||||
status_code = 404 if response.status_code == 404 else 502
|
||||
raise SBOMNexusError(status_code, detail)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise SBOMNexusError(502, "SBOM Nexus returned invalid JSON") from exc
|
||||
|
||||
|
||||
async def get_json(path: str, *, params: dict[str, Any] | None = None) -> Any:
|
||||
return await request_json("GET", path, params=params)
|
||||
|
||||
|
||||
async def post_json(path: str, *, body: dict[str, Any]) -> Any:
|
||||
return await request_json("POST", path, body=body)
|
||||
63
api/services/schema_state.py
Normal file
63
api/services/schema_state.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Report whether the database schema matches the code (STATE-WP-0083-T07).
|
||||
|
||||
Central ran two revisions behind the code it was serving, and `review_contracts`
|
||||
did not exist there although its migration shipped inside the running image.
|
||||
Nothing surfaced that: the API starts happily against a schema it was not built
|
||||
for, and only fails when a request happens to touch the missing table.
|
||||
|
||||
A hub that cannot say which schema it is running has the same problem as a
|
||||
projection that cannot name its source commit — it is asserting correctness it
|
||||
cannot demonstrate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def code_head_revision() -> str | None:
|
||||
"""The head revision the shipped migration scripts define.
|
||||
|
||||
Read from the migration files rather than the database: this is what the
|
||||
code expects, and it must be knowable without a working connection.
|
||||
"""
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(_MIGRATIONS))
|
||||
heads = ScriptDirectory.from_config(cfg).get_heads()
|
||||
return heads[0] if len(heads) == 1 else ",".join(sorted(heads)) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def db_revision(session: AsyncSession) -> str | None:
|
||||
try:
|
||||
result = await session.execute(text("select version_num from alembic_version"))
|
||||
return result.scalar()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def schema_state(session: AsyncSession) -> dict[str, Any]:
|
||||
applied = await db_revision(session)
|
||||
expected = code_head_revision()
|
||||
# "unknown" is deliberately not "ok": an instance that cannot determine its
|
||||
# own schema state must not report agreement it has not established.
|
||||
if applied is None or expected is None:
|
||||
status = "unknown"
|
||||
elif applied == expected:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "behind"
|
||||
return {"status": status, "applied": applied, "expected": expected}
|
||||
|
|
@ -27,6 +27,8 @@ 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
|
||||
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
|
||||
from api.services.sbom_nexus import reads_from_nexus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -88,9 +90,17 @@ async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
|
|||
if value is not None:
|
||||
core_parts.append(value)
|
||||
|
||||
sbom_at = (
|
||||
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
|
||||
).scalar_one_or_none()
|
||||
if reads_from_nexus():
|
||||
snapshots = await get_sbom_nexus_json("/sbom/snapshots/")
|
||||
snapshot_times = [
|
||||
datetime.fromisoformat(item["snapshot_at"].replace("Z", "+00:00"))
|
||||
for item in snapshots
|
||||
]
|
||||
sbom_at = max(snapshot_times, default=None)
|
||||
else:
|
||||
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)))
|
||||
|
|
@ -285,4 +295,4 @@ async def apply_progress_section(
|
|||
cache._entry.progress_revision = revision.progress_fingerprint()
|
||||
else:
|
||||
cache.store(merged, revision)
|
||||
return merged
|
||||
return merged
|
||||
|
|
|
|||
214
api/services/task_record_id_backfill.py
Normal file
214
api/services/task_record_id_backfill.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Backfill canonical record ids onto existing task rows (STATE-WP-0083-T06).
|
||||
|
||||
Only the repository files hold the mapping. A file task declares both its
|
||||
canonical id and the projection UUID it was registered under:
|
||||
|
||||
```task
|
||||
id: CUST-WP-0067-T01
|
||||
state_hub_task_id: "f3608db4-..."
|
||||
```
|
||||
|
||||
so the pairing can be read directly rather than guessed from titles. Anything a
|
||||
file does not claim is left alone: a task row whose canonical id cannot be
|
||||
established keeps `record_id` null, and the reset continues to refuse to act on
|
||||
it. An unknown identity must stay unknown rather than be inferred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||
_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE)
|
||||
_UUID_RE = re.compile(r'state_hub_task_id:\s*"?([0-9a-f-]{36})"?')
|
||||
_FRONTMATTER_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE)
|
||||
# A task id written as a bare "T01" is unique only inside its own workplan.
|
||||
# Storing it as a canonical identifier makes every workplan's first task share
|
||||
# one identity — 51 such ids were assigned to 148 rows before this was caught.
|
||||
_SHORT_TASK_ID_RE = re.compile(r"^T\d+$", re.IGNORECASE)
|
||||
|
||||
|
||||
def qualify_task_id(task_id: str, workplan_id: str | None) -> str | None:
|
||||
"""Return a canonical task id, or None when identity cannot be established."""
|
||||
task_id = task_id.strip()
|
||||
if not _SHORT_TASK_ID_RE.fullmatch(task_id):
|
||||
return task_id
|
||||
if not workplan_id:
|
||||
# Unqualifiable: leaving it unidentified is correct, since an identity
|
||||
# that is not unique is worse than none.
|
||||
return None
|
||||
return f"{workplan_id.strip()}-{task_id.upper()}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackfillReport:
|
||||
scanned_files: int = 0
|
||||
pairs_found: int = 0
|
||||
updated: int = 0
|
||||
already_set: int = 0
|
||||
conflicts: list[dict[str, str]] = field(default_factory=list)
|
||||
unmatched_uuids: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "state-hub.task-record-id-backfill.v1",
|
||||
"scanned_files": self.scanned_files,
|
||||
"pairs_found": self.pairs_found,
|
||||
"updated": self.updated,
|
||||
"already_set": self.already_set,
|
||||
"unmatched_uuids": self.unmatched_uuids,
|
||||
"conflicts": self.conflicts,
|
||||
}
|
||||
|
||||
|
||||
def collect_pairs(roots: list[Path]) -> tuple[dict[str, str], BackfillReport]:
|
||||
"""Map projection UUID -> canonical record id, from workplan files."""
|
||||
report = BackfillReport()
|
||||
pairs: dict[str, str] = {}
|
||||
for root in roots:
|
||||
wp_dir = root / "workplans"
|
||||
if not wp_dir.is_dir():
|
||||
continue
|
||||
for path in sorted(wp_dir.rglob("*.md")):
|
||||
if path.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
report.scanned_files += 1
|
||||
head = text.split("---", 2)[1] if text.startswith("---") and text.count("---") >= 2 else ""
|
||||
wp_match = _FRONTMATTER_ID_RE.search(head)
|
||||
workplan_id = wp_match.group(1).strip() if wp_match else None
|
||||
for block in _TASK_BLOCK_RE.finditer(text):
|
||||
body = block.group(1)
|
||||
rid = _ID_RE.search(body)
|
||||
uid = _UUID_RE.search(body)
|
||||
if not rid or not uid:
|
||||
continue
|
||||
record_id = qualify_task_id(rid.group(1), workplan_id)
|
||||
if record_id is None:
|
||||
continue
|
||||
task_uuid = uid.group(1)
|
||||
prior = pairs.get(task_uuid)
|
||||
if prior and prior != record_id:
|
||||
# One UUID claimed by two canonical ids: a duplicate
|
||||
# registration. Recording it and skipping is the only safe
|
||||
# option — picking one would fabricate an identity.
|
||||
report.conflicts.append(
|
||||
{"uuid": task_uuid, "first": prior, "second": record_id}
|
||||
)
|
||||
continue
|
||||
pairs[task_uuid] = record_id
|
||||
report.pairs_found = len(pairs)
|
||||
return pairs, report
|
||||
|
||||
|
||||
async def backfill_task_record_ids(
|
||||
session: Any, roots: list[Path], *, dry_run: bool = True
|
||||
) -> BackfillReport:
|
||||
from sqlalchemy import select
|
||||
|
||||
from api.models.task import Task
|
||||
|
||||
pairs, report = collect_pairs(roots)
|
||||
if not pairs:
|
||||
return report
|
||||
|
||||
rows = list((await session.execute(select(Task))).scalars())
|
||||
by_id = {str(r.id): r for r in rows}
|
||||
for task_uuid, record_id in pairs.items():
|
||||
row = by_id.get(task_uuid)
|
||||
if row is None:
|
||||
report.unmatched_uuids += 1
|
||||
continue
|
||||
if row.record_id == record_id:
|
||||
report.already_set += 1
|
||||
continue
|
||||
if row.record_id and row.record_id != record_id:
|
||||
report.conflicts.append(
|
||||
{"uuid": task_uuid, "first": row.record_id, "second": record_id}
|
||||
)
|
||||
continue
|
||||
report.updated += 1
|
||||
if not dry_run:
|
||||
row.record_id = record_id
|
||||
return report
|
||||
|
||||
|
||||
async def backfill_from_forge(
|
||||
session: Any,
|
||||
repo_slugs: list[str],
|
||||
*,
|
||||
forge_base: str | None = None,
|
||||
dry_run: bool = True,
|
||||
) -> BackfillReport:
|
||||
"""Backfill from repositories cloned out of the forge.
|
||||
|
||||
The local-path variant above needs a workstation checkout, which central
|
||||
does not have and should not depend on: `ADR-012` decision 1 makes the forge
|
||||
the projection source, and a backfill sourced from someone's laptop would
|
||||
reintroduce exactly the coupling that ADR removes.
|
||||
|
||||
Central can clone the forge directly, so it reads the pairing from the same
|
||||
place it derives everything else.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
from api.services.forge_projection import (
|
||||
DEFAULT_FORGE_BASE,
|
||||
ForgeDeriveError,
|
||||
_run_git,
|
||||
)
|
||||
|
||||
base = forge_base or DEFAULT_FORGE_BASE
|
||||
report = BackfillReport()
|
||||
pairs: dict[str, str] = {}
|
||||
|
||||
for slug in repo_slugs:
|
||||
url = f"{base.rstrip('/')}/{slug}.git"
|
||||
with tempfile.TemporaryDirectory(prefix=f"backfill-{slug}-") as tmp:
|
||||
try:
|
||||
_run_git("clone", "--depth", "1", "--quiet", url, tmp)
|
||||
except (ForgeDeriveError, Exception):
|
||||
# A repository that cannot be read contributes nothing. It must
|
||||
# not silently reduce what the rest can identify.
|
||||
continue
|
||||
repo_pairs, repo_report = collect_pairs([Path(tmp)])
|
||||
report.scanned_files += repo_report.scanned_files
|
||||
report.conflicts.extend(repo_report.conflicts)
|
||||
for uid, rid in repo_pairs.items():
|
||||
prior = pairs.get(uid)
|
||||
if prior and prior != rid:
|
||||
report.conflicts.append({"uuid": uid, "first": prior, "second": rid})
|
||||
continue
|
||||
pairs[uid] = rid
|
||||
|
||||
report.pairs_found = len(pairs)
|
||||
if not pairs:
|
||||
return report
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from api.models.task import Task
|
||||
|
||||
rows = list((await session.execute(select(Task))).scalars())
|
||||
by_id = {str(r.id): r for r in rows}
|
||||
for uid, rid in pairs.items():
|
||||
row = by_id.get(uid)
|
||||
if row is None:
|
||||
report.unmatched_uuids += 1
|
||||
continue
|
||||
if row.record_id == rid:
|
||||
report.already_set += 1
|
||||
continue
|
||||
if row.record_id and row.record_id != rid:
|
||||
report.conflicts.append({"uuid": uid, "first": row.record_id, "second": rid})
|
||||
continue
|
||||
report.updated += 1
|
||||
if not dry_run:
|
||||
row.record_id = rid
|
||||
return report
|
||||
799
api/services/work_record_identifier_migration.py
Normal file
799
api/services/work_record_identifier_migration.py
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
"""Transactional State Hub projection migration for deterministic work-record UUIDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1"
|
||||
FLEET_NAMESPACE = "helixforge"
|
||||
DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
|
||||
_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$")
|
||||
|
||||
|
||||
class IdentifierMigrationError(ValueError):
|
||||
"""The signed plan or current projection is unsafe to migrate."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentifierMigrationResult:
|
||||
repo_slug: str
|
||||
plan_sha256: str
|
||||
direction: str
|
||||
replacements: int
|
||||
migrated: int
|
||||
already_derived: int
|
||||
assignments_deferred: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SealedProjectionRepairReceipt:
|
||||
"""Non-secret evidence for one bounded pre-derivation projection repair."""
|
||||
|
||||
schema: str
|
||||
outcome: str
|
||||
repo_slug: str
|
||||
repository_id: str
|
||||
workplan_record_id: str
|
||||
workplan_old_id: str
|
||||
task_records: tuple[tuple[str, str], ...]
|
||||
plan_sha256: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
projection_identity: str
|
||||
observed_at: str
|
||||
|
||||
|
||||
def verify_plan(plan: dict[str, Any]) -> str:
|
||||
"""Validate the immutable plan envelope and deterministic UUID mappings."""
|
||||
if plan.get("schema") != PLAN_SCHEMA:
|
||||
raise IdentifierMigrationError("unsupported identifier migration plan schema")
|
||||
if plan.get("namespace") != FLEET_NAMESPACE:
|
||||
raise IdentifierMigrationError(
|
||||
f"plan namespace must be {FLEET_NAMESPACE!r}"
|
||||
)
|
||||
if plan.get("apply_policy") != "all-or-nothing per repository":
|
||||
raise IdentifierMigrationError("plan does not declare repository-atomic apply")
|
||||
if plan.get("ready_to_apply") is not True:
|
||||
raise IdentifierMigrationError("plan is not ready_to_apply")
|
||||
|
||||
expected = plan.get("plan_sha256")
|
||||
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
|
||||
raise IdentifierMigrationError("plan has no valid SHA-256 seal")
|
||||
unsealed = {key: value for key, value in plan.items() if key != "plan_sha256"}
|
||||
canonical = json.dumps(unsealed, sort_keys=True, separators=(",", ":")).encode()
|
||||
actual = hashlib.sha256(canonical).hexdigest()
|
||||
if actual != expected:
|
||||
raise IdentifierMigrationError("plan SHA-256 mismatch")
|
||||
return expected
|
||||
|
||||
|
||||
def _repository_mappings(
|
||||
plan: dict[str, Any], repo_slug: str
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
|
||||
if len(matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"repository {repo_slug!r} must occur exactly once in plan"
|
||||
)
|
||||
repository = matches[0]
|
||||
if repository.get("eligible") is not True or repository.get("atomic_unit") is not True:
|
||||
raise IdentifierMigrationError(f"repository {repo_slug!r} is not eligible and atomic")
|
||||
|
||||
replacements: list[dict[str, Any]] = []
|
||||
assignments = 0
|
||||
seen_old: set[uuid.UUID] = set()
|
||||
seen_new: set[uuid.UUID] = set()
|
||||
for raw in repository.get("mappings", []):
|
||||
action = raw.get("action")
|
||||
if action == "assign":
|
||||
assignments += 1
|
||||
continue
|
||||
if action == "unchanged":
|
||||
continue
|
||||
if action != "replace":
|
||||
raise IdentifierMigrationError(f"unsupported mapping action {action!r}")
|
||||
if raw.get("repo") != repo_slug:
|
||||
raise IdentifierMigrationError("mapping repository does not match atomic unit")
|
||||
kind = raw.get("kind")
|
||||
record_id = raw.get("record_id")
|
||||
if kind not in {"workplan", "task"}:
|
||||
raise IdentifierMigrationError(f"unsupported record kind {kind!r}")
|
||||
if not isinstance(record_id, str) or not _RECORD_ID_RE.fullmatch(record_id):
|
||||
raise IdentifierMigrationError(f"noncanonical record id {record_id!r}")
|
||||
try:
|
||||
old_id = uuid.UUID(str(raw.get("current_uuid")))
|
||||
new_id = uuid.UUID(str(raw.get("derived_uuid")))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise IdentifierMigrationError(f"invalid UUID mapping for {record_id}") from exc
|
||||
expected_new = uuid.uuid5(
|
||||
DERIVATION_NAMESPACE_UUID,
|
||||
f"{FLEET_NAMESPACE}\n{record_id}",
|
||||
)
|
||||
if new_id != expected_new:
|
||||
raise IdentifierMigrationError(f"derived UUID mismatch for {record_id}")
|
||||
if old_id == new_id or old_id in seen_old or new_id in seen_new:
|
||||
raise IdentifierMigrationError(f"non-unique replacement for {record_id}")
|
||||
seen_old.add(old_id)
|
||||
seen_new.add(new_id)
|
||||
replacements.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"record_id": record_id,
|
||||
"old_id": old_id,
|
||||
"new_id": new_id,
|
||||
}
|
||||
)
|
||||
return replacements, assignments
|
||||
|
||||
|
||||
def _repository_entry(plan: dict[str, Any], repo_slug: str) -> dict[str, Any]:
|
||||
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
|
||||
if len(matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"repository {repo_slug!r} must occur exactly once in plan"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _repair_unit_mappings(
|
||||
plan: dict[str, Any], repo_slug: str, unit: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
replacements, _assignments = _repository_mappings(plan, repo_slug)
|
||||
workplan = unit.get("workplan")
|
||||
tasks = unit.get("tasks")
|
||||
if not isinstance(workplan, dict) or not isinstance(tasks, list):
|
||||
raise IdentifierMigrationError("repair unit requires one workplan and a task list")
|
||||
|
||||
workplan_record_id = workplan.get("record_id")
|
||||
workplan_matches = [
|
||||
mapping
|
||||
for mapping in replacements
|
||||
if mapping["kind"] == "workplan" and mapping["record_id"] == workplan_record_id
|
||||
]
|
||||
if len(workplan_matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
"repair workplan must occur exactly once as a replacement in the sealed plan"
|
||||
)
|
||||
workplan_mapping = workplan_matches[0]
|
||||
raw_workplan_mapping = next(
|
||||
raw
|
||||
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
|
||||
if raw.get("kind") == "workplan" and raw.get("record_id") == workplan_record_id
|
||||
)
|
||||
authoritative_path = raw_workplan_mapping.get("path")
|
||||
if not isinstance(authoritative_path, str) or not authoritative_path:
|
||||
raise IdentifierMigrationError("sealed workplan mapping has no authoritative path")
|
||||
|
||||
task_record_ids = [task.get("record_id") for task in tasks if isinstance(task, dict)]
|
||||
if len(task_record_ids) != len(tasks) or len(set(task_record_ids)) != len(tasks):
|
||||
raise IdentifierMigrationError("repair task record ids must be explicit and unique")
|
||||
task_mappings = [
|
||||
mapping
|
||||
for mapping in replacements
|
||||
if mapping["kind"] == "task" and mapping["record_id"] in task_record_ids
|
||||
]
|
||||
mapped_task_ids = {mapping["record_id"] for mapping in task_mappings}
|
||||
if mapped_task_ids != set(task_record_ids):
|
||||
raise IdentifierMigrationError(
|
||||
"repair tasks do not exactly match replacement mappings in the sealed plan"
|
||||
)
|
||||
|
||||
raw_unit_task_ids = {
|
||||
raw.get("record_id")
|
||||
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
|
||||
if raw.get("kind") == "task"
|
||||
and raw.get("action") == "replace"
|
||||
and raw.get("path") == authoritative_path
|
||||
}
|
||||
if raw_unit_task_ids != set(task_record_ids):
|
||||
raise IdentifierMigrationError(
|
||||
"repair must include every replaced task in the sealed workplan unit"
|
||||
)
|
||||
return workplan_mapping, task_mappings
|
||||
|
||||
|
||||
def _uuid_field(value: Any, label: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise IdentifierMigrationError(f"invalid UUID for {label}") from exc
|
||||
|
||||
|
||||
def _repair_payloads(
|
||||
unit: dict[str, Any],
|
||||
workplan_mapping: dict[str, Any],
|
||||
task_mappings: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
raw_workplan = unit["workplan"]
|
||||
if _uuid_field(raw_workplan.get("id"), "workplan") != workplan_mapping["old_id"]:
|
||||
raise IdentifierMigrationError("repair workplan UUID does not match sealed old UUID")
|
||||
slug = raw_workplan.get("slug")
|
||||
title = raw_workplan.get("title")
|
||||
status = raw_workplan.get("status")
|
||||
if not isinstance(slug, str) or not slug or not isinstance(title, str) or not title:
|
||||
raise IdentifierMigrationError("repair workplan requires explicit slug and title")
|
||||
if status not in {
|
||||
"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"
|
||||
}:
|
||||
raise IdentifierMigrationError(f"unsupported repair workplan status {status!r}")
|
||||
workplan_payload = {
|
||||
"id": workplan_mapping["old_id"],
|
||||
"topic_id": _uuid_field(raw_workplan.get("topic_id"), "workplan topic"),
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"description": raw_workplan.get("description"),
|
||||
"status": status,
|
||||
"owner": raw_workplan.get("owner"),
|
||||
"planning_priority": raw_workplan.get("planning_priority"),
|
||||
"planning_order": raw_workplan.get("planning_order"),
|
||||
}
|
||||
|
||||
mappings_by_record = {mapping["record_id"]: mapping for mapping in task_mappings}
|
||||
task_payloads: list[dict[str, Any]] = []
|
||||
for raw_task in unit["tasks"]:
|
||||
mapping = mappings_by_record[raw_task["record_id"]]
|
||||
if _uuid_field(raw_task.get("id"), raw_task["record_id"]) != mapping["old_id"]:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task UUID does not match sealed old UUID for {raw_task['record_id']}"
|
||||
)
|
||||
title = raw_task.get("title")
|
||||
if not isinstance(title, str) or not title:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} requires an explicit title"
|
||||
)
|
||||
try:
|
||||
status_value = TaskStatus(raw_task.get("status", "todo"))
|
||||
priority_value = TaskPriority(raw_task.get("priority", "medium"))
|
||||
except ValueError as exc:
|
||||
raise IdentifierMigrationError(
|
||||
f"invalid task state for {raw_task['record_id']}"
|
||||
) from exc
|
||||
parent_record_id = raw_task.get("parent_record_id")
|
||||
if parent_record_id is not None and parent_record_id not in mappings_by_record:
|
||||
raise IdentifierMigrationError(
|
||||
f"parent task for {raw_task['record_id']} is outside the sealed repair unit"
|
||||
)
|
||||
task_payloads.append(
|
||||
{
|
||||
"record_id": raw_task["record_id"],
|
||||
"id": mapping["old_id"],
|
||||
"title": title,
|
||||
"description": raw_task.get("description"),
|
||||
"status": status_value,
|
||||
"priority": priority_value,
|
||||
"assignee": raw_task.get("assignee"),
|
||||
"blocking_reason": raw_task.get("blocking_reason"),
|
||||
"needs_human": bool(raw_task.get("needs_human", False)),
|
||||
"intervention_note": raw_task.get("intervention_note"),
|
||||
"parent_task_id": (
|
||||
mappings_by_record[parent_record_id]["old_id"]
|
||||
if parent_record_id is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
if task_payloads[-1]["needs_human"] and not task_payloads[-1]["intervention_note"]:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} needs an intervention note"
|
||||
)
|
||||
if (
|
||||
task_payloads[-1]["status"] == TaskStatus.wait
|
||||
and task_payloads[-1]["needs_human"]
|
||||
and not task_payloads[-1]["blocking_reason"]
|
||||
):
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} needs a blocking reason"
|
||||
)
|
||||
|
||||
pending = list(task_payloads)
|
||||
ordered: list[dict[str, Any]] = []
|
||||
emitted: set[uuid.UUID] = set()
|
||||
while pending:
|
||||
ready = [
|
||||
task
|
||||
for task in pending
|
||||
if task["parent_task_id"] is None or task["parent_task_id"] in emitted
|
||||
]
|
||||
if not ready:
|
||||
raise IdentifierMigrationError("repair task parentage contains a cycle")
|
||||
for task in ready:
|
||||
pending.remove(task)
|
||||
ordered.append(task)
|
||||
emitted.add(task["id"])
|
||||
return workplan_payload, ordered
|
||||
|
||||
|
||||
def _same_workplan(row: Workplan, expected: dict[str, Any], repo_id: uuid.UUID) -> bool:
|
||||
fields = (
|
||||
"id", "topic_id", "slug", "title", "description", "status", "owner",
|
||||
"planning_priority", "planning_order",
|
||||
)
|
||||
return row.repo_id == repo_id and all(
|
||||
getattr(row, field) == expected[field] for field in fields
|
||||
)
|
||||
|
||||
|
||||
def _same_task(row: Task, expected: dict[str, Any], workplan_id: uuid.UUID) -> bool:
|
||||
fields = (
|
||||
"id", "title", "description", "status", "priority", "assignee",
|
||||
"blocking_reason", "needs_human", "intervention_note", "parent_task_id",
|
||||
)
|
||||
return row.workplan_id == workplan_id and all(
|
||||
getattr(row, field) == expected[field] for field in fields
|
||||
)
|
||||
|
||||
|
||||
_TASK_DUPLICATE_FIELDS = (
|
||||
"workplan_id",
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"priority",
|
||||
"assignee",
|
||||
"due_date",
|
||||
"blocking_reason",
|
||||
"needs_human",
|
||||
"intervention_note",
|
||||
"parent_task_id",
|
||||
)
|
||||
|
||||
|
||||
def _same_duplicate_task(
|
||||
legacy: Task,
|
||||
derived: Task,
|
||||
record_id: str,
|
||||
) -> bool:
|
||||
"""Accept only a deterministic duplicate of an otherwise identical legacy row."""
|
||||
return (
|
||||
legacy.record_id in {None, record_id}
|
||||
and derived.record_id == record_id
|
||||
and all(
|
||||
getattr(legacy, field) == getattr(derived, field)
|
||||
for field in _TASK_DUPLICATE_FIELDS
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _task_reference_count(session: AsyncSession, task_id: uuid.UUID) -> int:
|
||||
"""Count every inbound task reference before duplicate coalescence.
|
||||
|
||||
Coalescence deliberately refuses referenced legacy rows. Updating references
|
||||
could collide with relationships already attached to the deterministic row;
|
||||
a zero-reference proof keeps the repair lossless and reversible.
|
||||
"""
|
||||
return int(
|
||||
await session.scalar(
|
||||
text(
|
||||
"SELECT "
|
||||
"(SELECT count(*) FROM token_events WHERE task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM tasks WHERE parent_task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM review_contracts WHERE task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM progress_events WHERE task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM capability_requests WHERE blocking_task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM workplan_dependencies WHERE to_task_id = :task_id) + "
|
||||
"(SELECT count(*) FROM suggestions WHERE promoted_task_id = :task_id)"
|
||||
),
|
||||
{"task_id": task_id},
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
async def repair_absent_prederivation_projection(
|
||||
session: AsyncSession,
|
||||
plan: dict[str, Any],
|
||||
repo_slug: str,
|
||||
unit: dict[str, Any],
|
||||
*,
|
||||
expected_plan_sha256: str,
|
||||
source_revision: str,
|
||||
source_fingerprint: str,
|
||||
source_clean: bool,
|
||||
source_synchronized: bool,
|
||||
primary_confirmed: bool,
|
||||
projection_identity: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> SealedProjectionRepairReceipt:
|
||||
"""Restore one exact random-ID workplan unit into an absent projection.
|
||||
|
||||
This is intentionally separate from ordinary consistency repair. The caller
|
||||
must independently prove Git cleanliness/synchronization and provide the
|
||||
authoritative unit parsed from the pinned source revision.
|
||||
"""
|
||||
plan_sha256 = verify_plan(plan)
|
||||
if plan_sha256 != expected_plan_sha256:
|
||||
raise IdentifierMigrationError("explicit plan SHA-256 does not match sealed plan")
|
||||
if not primary_confirmed:
|
||||
raise IdentifierMigrationError("projection repair requires explicit primary confirmation")
|
||||
if not source_clean or not source_synchronized:
|
||||
raise IdentifierMigrationError("projection repair requires a clean synchronized source")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", source_revision):
|
||||
raise IdentifierMigrationError("source revision must be a full Git SHA")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
||||
raise IdentifierMigrationError("source fingerprint must be SHA-256")
|
||||
if not projection_identity.strip():
|
||||
raise IdentifierMigrationError("projection identity is required")
|
||||
|
||||
repository_entry = _repository_entry(plan, repo_slug)
|
||||
if source_revision != repository_entry.get("planned_head_sha"):
|
||||
raise IdentifierMigrationError("source revision drifted from sealed plan")
|
||||
if source_fingerprint != repository_entry.get("source_fingerprint"):
|
||||
raise IdentifierMigrationError("source fingerprint drifted from sealed plan")
|
||||
workplan_mapping, task_mappings = _repair_unit_mappings(plan, repo_slug, unit)
|
||||
workplan_payload, task_payloads = _repair_payloads(
|
||||
unit, workplan_mapping, task_mappings
|
||||
)
|
||||
if session.in_transaction():
|
||||
raise IdentifierMigrationError("projection repair requires a fresh database session")
|
||||
|
||||
outcome = "repaired"
|
||||
async with session.begin():
|
||||
resolution = await resolve_repository_slug(session, repo_slug, required=False)
|
||||
if resolution is None:
|
||||
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
|
||||
repo = await session.scalar(
|
||||
select(ManagedRepo)
|
||||
.where(ManagedRepo.id == resolution.repo.id)
|
||||
.with_for_update()
|
||||
)
|
||||
if repo.topic_id != workplan_payload["topic_id"]:
|
||||
raise IdentifierMigrationError("repair topic does not match repository projection")
|
||||
|
||||
old_ids = [workplan_mapping["old_id"], *(m["old_id"] for m in task_mappings)]
|
||||
new_workplan = await session.get(Workplan, workplan_mapping["new_id"])
|
||||
new_tasks = [await session.get(Task, mapping["new_id"]) for mapping in task_mappings]
|
||||
if new_workplan is not None or any(task is not None for task in new_tasks):
|
||||
raise IdentifierMigrationError("derived target presence blocks projection repair")
|
||||
alias_count = await session.scalar(
|
||||
select(func.count()).select_from(WorkRecordIdentifierAlias).where(
|
||||
WorkRecordIdentifierAlias.old_id.in_(old_ids)
|
||||
)
|
||||
)
|
||||
if alias_count:
|
||||
raise IdentifierMigrationError("durable alias presence blocks projection repair")
|
||||
|
||||
old_workplan = await session.get(Workplan, workplan_mapping["old_id"])
|
||||
old_tasks = [await session.get(Task, mapping["old_id"]) for mapping in task_mappings]
|
||||
present_count = int(old_workplan is not None) + sum(task is not None for task in old_tasks)
|
||||
expected_count = 1 + len(task_mappings)
|
||||
if present_count not in {0, expected_count}:
|
||||
raise IdentifierMigrationError("partial old projection presence blocks repair")
|
||||
|
||||
if present_count == expected_count:
|
||||
if not _same_workplan(old_workplan, workplan_payload, repo.id):
|
||||
raise IdentifierMigrationError(
|
||||
"existing old workplan differs from sealed repair unit"
|
||||
)
|
||||
expected_tasks = {task["id"]: task for task in task_payloads}
|
||||
if any(
|
||||
not _same_task(task, expected_tasks[task.id], workplan_mapping["old_id"])
|
||||
for task in old_tasks
|
||||
):
|
||||
raise IdentifierMigrationError("existing old tasks differ from sealed repair unit")
|
||||
outcome = "verified_noop"
|
||||
else:
|
||||
slug_conflict = await session.scalar(
|
||||
select(Workplan.id).where(Workplan.slug == workplan_payload["slug"])
|
||||
)
|
||||
if slug_conflict is not None:
|
||||
raise IdentifierMigrationError("workplan slug is already projected")
|
||||
session.add(
|
||||
Workplan(repo_id=repo.id, **workplan_payload)
|
||||
)
|
||||
await session.flush()
|
||||
for task_payload in task_payloads:
|
||||
values = {key: value for key, value in task_payload.items() if key != "record_id"}
|
||||
session.add(Task(workplan_id=workplan_mapping["old_id"], **values))
|
||||
await session.flush()
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
raise IdentifierMigrationError("receipt observation time must be timezone-aware")
|
||||
return SealedProjectionRepairReceipt(
|
||||
schema="state-hub.sealed-prederivation-projection-repair-receipt.v1",
|
||||
outcome=outcome,
|
||||
repo_slug=repo_slug,
|
||||
repository_id=str(repo.id),
|
||||
workplan_record_id=workplan_mapping["record_id"],
|
||||
workplan_old_id=str(workplan_mapping["old_id"]),
|
||||
task_records=tuple(
|
||||
(mapping["record_id"], str(mapping["old_id"])) for mapping in task_mappings
|
||||
),
|
||||
plan_sha256=plan_sha256,
|
||||
source_revision=source_revision,
|
||||
source_fingerprint=source_fingerprint,
|
||||
projection_identity=projection_identity,
|
||||
observed_at=timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
||||
)
|
||||
|
||||
|
||||
async def _assert_projection_preconditions(
|
||||
session: AsyncSession,
|
||||
repository_id: uuid.UUID,
|
||||
repo_slug: str,
|
||||
replacements: list[dict[str, Any]],
|
||||
*,
|
||||
reverse: bool,
|
||||
) -> dict[str, str]:
|
||||
states: dict[str, str] = {}
|
||||
for mapping in replacements:
|
||||
source_id = mapping["new_id"] if reverse else mapping["old_id"]
|
||||
target_id = mapping["old_id"] if reverse else mapping["new_id"]
|
||||
if mapping["kind"] == "workplan":
|
||||
query = text(
|
||||
"SELECT workplans.id, workplans.repo_id, workplans.slug "
|
||||
"FROM workplans WHERE workplans.id = :record_id FOR UPDATE"
|
||||
)
|
||||
else:
|
||||
query = text(
|
||||
"SELECT tasks.id, workplans.repo_id, tasks.record_id "
|
||||
"FROM tasks "
|
||||
"JOIN workplans ON workplans.id = tasks.workplan_id "
|
||||
"WHERE tasks.id = :record_id FOR UPDATE"
|
||||
)
|
||||
source = (await session.execute(query, {"record_id": source_id})).one_or_none()
|
||||
target = (await session.execute(query, {"record_id": target_id})).one_or_none()
|
||||
for label, row in (("source", source), ("target", target)):
|
||||
if row is not None and row.repo_id != repository_id:
|
||||
raise IdentifierMigrationError(
|
||||
f"{label} {mapping['kind']} {row.id} belongs to another repository"
|
||||
)
|
||||
|
||||
if reverse:
|
||||
if source is None:
|
||||
raise IdentifierMigrationError(
|
||||
f"source {mapping['kind']} {source_id} is absent from repository {repo_slug}"
|
||||
)
|
||||
if target is not None:
|
||||
raise IdentifierMigrationError(
|
||||
f"target {mapping['kind']} {target_id} already exists"
|
||||
)
|
||||
states[mapping["record_id"]] = "derived_source"
|
||||
continue
|
||||
|
||||
if source is not None and target is not None:
|
||||
if mapping["kind"] != "task":
|
||||
raise IdentifierMigrationError(
|
||||
f"both legacy and derived {mapping['kind']} rows exist for {mapping['record_id']}"
|
||||
)
|
||||
legacy_task = await session.scalar(
|
||||
select(Task).where(Task.id == source_id).with_for_update()
|
||||
)
|
||||
derived_task = await session.scalar(
|
||||
select(Task).where(Task.id == target_id).with_for_update()
|
||||
)
|
||||
if (
|
||||
legacy_task is None
|
||||
or derived_task is None
|
||||
or not _same_duplicate_task(
|
||||
legacy_task, derived_task, mapping["record_id"]
|
||||
)
|
||||
):
|
||||
raise IdentifierMigrationError(
|
||||
f"legacy and derived task rows differ for {mapping['record_id']}"
|
||||
)
|
||||
references = await _task_reference_count(session, source_id)
|
||||
if references:
|
||||
raise IdentifierMigrationError(
|
||||
f"legacy duplicate task has {references} inbound references for "
|
||||
f"{mapping['record_id']}"
|
||||
)
|
||||
states[mapping["record_id"]] = "duplicate_target"
|
||||
continue
|
||||
if source is None and target is None:
|
||||
raise IdentifierMigrationError(
|
||||
f"neither legacy nor derived {mapping['kind']} row exists for {mapping['record_id']}"
|
||||
)
|
||||
if source is not None:
|
||||
states[mapping["record_id"]] = "legacy_source"
|
||||
continue
|
||||
|
||||
# A pre-existing derived target is safe only when it already represents
|
||||
# the canonical record from this repository. This is the state produced
|
||||
# when forge reconciliation reached a partial projection before the
|
||||
# sealed file migration did.
|
||||
identity = target.slug if mapping["kind"] == "workplan" else target.record_id
|
||||
if not isinstance(identity, str) or identity.strip().lower() != mapping["record_id"].lower():
|
||||
raise IdentifierMigrationError(
|
||||
f"derived target identity mismatch for {mapping['record_id']}"
|
||||
)
|
||||
states[mapping["record_id"]] = "derived_target"
|
||||
return states
|
||||
|
||||
|
||||
async def apply_repository_identifier_migration(
|
||||
session: AsyncSession,
|
||||
plan: dict[str, Any],
|
||||
repo_slug: str,
|
||||
) -> IdentifierMigrationResult:
|
||||
"""Replace one repository's projected UUIDs in one database transaction."""
|
||||
plan_sha256 = verify_plan(plan)
|
||||
replacements, assignments = _repository_mappings(plan, repo_slug)
|
||||
if session.in_transaction():
|
||||
raise IdentifierMigrationError("migration requires a fresh database session")
|
||||
|
||||
async with session.begin():
|
||||
resolution = await resolve_repository_slug(session, repo_slug, required=False)
|
||||
if resolution is None:
|
||||
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
|
||||
projection_states = await _assert_projection_preconditions(
|
||||
session, resolution.repo.id, repo_slug, replacements, reverse=False
|
||||
)
|
||||
aliases = {
|
||||
alias.old_id: alias
|
||||
for alias in (
|
||||
await session.execute(
|
||||
select(WorkRecordIdentifierAlias).where(
|
||||
WorkRecordIdentifierAlias.old_id.in_(
|
||||
[mapping["old_id"] for mapping in replacements]
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
for mapping in replacements:
|
||||
alias = aliases.get(mapping["old_id"])
|
||||
if alias is None:
|
||||
alias = WorkRecordIdentifierAlias(
|
||||
old_id=mapping["old_id"],
|
||||
new_id=mapping["new_id"],
|
||||
record_kind=mapping["kind"],
|
||||
record_id=mapping["record_id"],
|
||||
repo_slug=repo_slug,
|
||||
namespace=FLEET_NAMESPACE,
|
||||
plan_sha256=plan_sha256,
|
||||
)
|
||||
session.add(alias)
|
||||
aliases[mapping["old_id"]] = alias
|
||||
elif (
|
||||
alias.new_id != mapping["new_id"]
|
||||
or alias.record_kind != mapping["kind"]
|
||||
or alias.record_id != mapping["record_id"]
|
||||
or alias.repo_slug != repo_slug
|
||||
or alias.namespace != FLEET_NAMESPACE
|
||||
or alias.plan_sha256 != plan_sha256
|
||||
or alias.migration_status not in {"reversed", "applied"}
|
||||
):
|
||||
raise IdentifierMigrationError(
|
||||
f"conflicting durable alias for {mapping['old_id']}"
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
for mapping in (
|
||||
item
|
||||
for item in replacements
|
||||
if projection_states[item["record_id"]] == "duplicate_target"
|
||||
):
|
||||
result = await session.execute(
|
||||
text("DELETE FROM tasks WHERE id = :old_id"),
|
||||
{"old_id": mapping["old_id"]},
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"failed to coalesce duplicate {mapping['record_id']}"
|
||||
)
|
||||
|
||||
for kind in ("workplan", "task"):
|
||||
table = "workplans" if kind == "workplan" else "tasks"
|
||||
for mapping in (
|
||||
item
|
||||
for item in replacements
|
||||
if item["kind"] == kind
|
||||
and projection_states[item["record_id"]] == "legacy_source"
|
||||
):
|
||||
result = await session.execute(
|
||||
text(f"UPDATE {table} SET id = :new_id WHERE id = :old_id"),
|
||||
{"old_id": mapping["old_id"], "new_id": mapping["new_id"]},
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"failed to replace {mapping['record_id']}"
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for alias in aliases.values():
|
||||
alias.migration_status = "applied"
|
||||
alias.applied_at = now
|
||||
alias.reversed_at = None
|
||||
|
||||
return IdentifierMigrationResult(
|
||||
repo_slug=repo_slug,
|
||||
plan_sha256=plan_sha256,
|
||||
direction="forward",
|
||||
replacements=len(replacements),
|
||||
migrated=sum(
|
||||
state in {"legacy_source", "duplicate_target"}
|
||||
for state in projection_states.values()
|
||||
),
|
||||
already_derived=sum(
|
||||
state == "derived_target" for state in projection_states.values()
|
||||
),
|
||||
assignments_deferred=assignments,
|
||||
)
|
||||
|
||||
|
||||
async def reverse_repository_identifier_migration(
|
||||
session: AsyncSession,
|
||||
plan: dict[str, Any],
|
||||
repo_slug: str,
|
||||
) -> IdentifierMigrationResult:
|
||||
"""Restore one repository's prior projected UUIDs and retain aliases."""
|
||||
plan_sha256 = verify_plan(plan)
|
||||
replacements, assignments = _repository_mappings(plan, repo_slug)
|
||||
if session.in_transaction():
|
||||
raise IdentifierMigrationError("migration requires a fresh database session")
|
||||
|
||||
async with session.begin():
|
||||
resolution = await resolve_repository_slug(session, repo_slug, required=False)
|
||||
if resolution is None:
|
||||
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
|
||||
aliases = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(WorkRecordIdentifierAlias).where(
|
||||
WorkRecordIdentifierAlias.plan_sha256 == plan_sha256,
|
||||
WorkRecordIdentifierAlias.repo_slug == repo_slug,
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
aliases_by_old = {alias.old_id: alias for alias in aliases}
|
||||
if len(aliases_by_old) != len(replacements):
|
||||
raise IdentifierMigrationError("durable alias set is incomplete")
|
||||
for mapping in replacements:
|
||||
alias = aliases_by_old.get(mapping["old_id"])
|
||||
if (
|
||||
alias is None
|
||||
or alias.new_id != mapping["new_id"]
|
||||
or alias.record_kind != mapping["kind"]
|
||||
or alias.record_id != mapping["record_id"]
|
||||
or alias.migration_status != "applied"
|
||||
):
|
||||
raise IdentifierMigrationError(
|
||||
f"durable alias is not applied for {mapping['record_id']}"
|
||||
)
|
||||
await _assert_projection_preconditions(
|
||||
session, resolution.repo.id, repo_slug, replacements, reverse=True
|
||||
)
|
||||
|
||||
for kind in ("task", "workplan"):
|
||||
table = "tasks" if kind == "task" else "workplans"
|
||||
for mapping in (item for item in replacements if item["kind"] == kind):
|
||||
result = await session.execute(
|
||||
text(f"UPDATE {table} SET id = :old_id WHERE id = :new_id"),
|
||||
{"old_id": mapping["old_id"], "new_id": mapping["new_id"]},
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"failed to reverse {mapping['record_id']}"
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for alias in aliases:
|
||||
alias.migration_status = "reversed"
|
||||
alias.reversed_at = now
|
||||
|
||||
return IdentifierMigrationResult(
|
||||
repo_slug=repo_slug,
|
||||
plan_sha256=plan_sha256,
|
||||
direction="reverse",
|
||||
replacements=len(replacements),
|
||||
migrated=len(replacements),
|
||||
already_derived=0,
|
||||
assignments_deferred=assignments,
|
||||
)
|
||||
|
|
@ -41,15 +41,16 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
|
|||
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(
|
||||
"POST",
|
||||
r"/repos/[^/]+/work-record-projection/reconcile",
|
||||
"replace",
|
||||
"reconcile one forge-derived repository projection",
|
||||
),
|
||||
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
|
||||
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
|
||||
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
|
||||
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
|
||||
WriteRouteRule("POST", r"/suggestions", "append", "create suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/vet", "replace", "vet suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/decline", "replace", "decline suggestion"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/promote", "replace", "promote suggestion to task"),
|
||||
WriteRouteRule("POST", r"/suggestions/[^/]+/bump-relevance", "append", "bump suggestion relevance"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
48
api/work_record_flavor.py
Normal file
48
api/work_record_flavor.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Work-record flavor (STATE-WP-0092).
|
||||
|
||||
Flavor is a closed bucket on workplans and tasks, orthogonal to kind and
|
||||
status. Unset flavor is not residual.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
WORK_RECORD_FLAVORS: tuple[str, ...] = (
|
||||
"planning",
|
||||
"implementation",
|
||||
"refactoring",
|
||||
"extension",
|
||||
"residual",
|
||||
)
|
||||
RESIDUAL_FLAVOR = "residual"
|
||||
FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk")
|
||||
|
||||
_EMPTY = {"", "~", "null", "none", "nil"}
|
||||
|
||||
|
||||
def normalize_flavor(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
if text in _EMPTY:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def is_known_flavor(value: Any) -> bool:
|
||||
flavor = normalize_flavor(value)
|
||||
return flavor is None or flavor in WORK_RECORD_FLAVORS
|
||||
|
||||
|
||||
def is_residual_flavor(value: Any) -> bool:
|
||||
return normalize_flavor(value) == RESIDUAL_FLAVOR
|
||||
|
||||
|
||||
def normalize_promotion_reason(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
if text in _EMPTY:
|
||||
return None
|
||||
return text
|
||||
196
custodian_cli.py
196
custodian_cli.py
|
|
@ -18,9 +18,11 @@ import re
|
|||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from repository_rename_cli import configure_repo_commands
|
||||
from statehub_register import run_register as run_statehub_register
|
||||
|
||||
STATE_HUB_DIR = Path(__file__).resolve().parent
|
||||
|
|
@ -402,6 +404,8 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None:
|
|||
cmd.append("--remote")
|
||||
if args.no_writeback:
|
||||
cmd.append("--no-writeback")
|
||||
if getattr(args, "bootstrap_empty_projection", False):
|
||||
cmd.append("--bootstrap-empty-projection")
|
||||
if args.archive_closed:
|
||||
cmd.append("--archive-closed")
|
||||
if args.archive_workplan:
|
||||
|
|
@ -422,6 +426,53 @@ def cmd_fix_consistency(args: argparse.Namespace) -> None:
|
|||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def cmd_quality_debt(args: argparse.Namespace) -> None:
|
||||
"""List DoX quality debt (STATE-WP-0077) — ready without DoR-Ok, etc."""
|
||||
script = STATE_HUB_DIR / "scripts" / "quality_debt.py"
|
||||
if not script.exists():
|
||||
print(f"ERROR: quality_debt.py not found at {script}")
|
||||
sys.exit(1)
|
||||
cmd = [sys.executable, str(script)]
|
||||
if args.repo_path:
|
||||
cmd.extend(["--repo-path", str(Path(args.repo_path).expanduser().resolve())])
|
||||
else:
|
||||
cmd.append("--here")
|
||||
if args.api_base:
|
||||
cmd.extend(["--api-base", args.api_base])
|
||||
if args.no_hub:
|
||||
cmd.append("--no-hub")
|
||||
if args.as_json:
|
||||
cmd.append("--json")
|
||||
if args.strict:
|
||||
cmd.append("--strict")
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def cmd_promote_intake(args: argparse.Namespace) -> None:
|
||||
"""Promote a routed intake into a workplan, task, decision, or engagement."""
|
||||
script = STATE_HUB_DIR / "scripts" / "promote_intake.py"
|
||||
if not script.exists():
|
||||
print(f"ERROR: promote_intake.py not found at {script}")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = [
|
||||
sys.executable, str(script), args.intake_id,
|
||||
"--to", args.to,
|
||||
"--repo-path", str(Path(args.repo_path).expanduser().resolve()),
|
||||
"--repo-slug", args.repo_slug,
|
||||
"--domain", args.domain,
|
||||
"--api-base", args.api_base,
|
||||
]
|
||||
if args.target_file:
|
||||
cmd.extend(["--target-file", args.target_file])
|
||||
if args.workplan_file:
|
||||
cmd.extend(["--workplan-file", args.workplan_file])
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def cmd_create_workstream(args: argparse.Namespace) -> None:
|
||||
"""Create a workstream under a domain's topic."""
|
||||
_api_get("/state/health")
|
||||
|
|
@ -508,12 +559,22 @@ def cmd_status(_args: argparse.Namespace) -> None:
|
|||
"""Quick status: API health + summary totals."""
|
||||
health = _api_get("/state/health")
|
||||
print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}")
|
||||
# Which instance answered, not merely that one did.
|
||||
role = health.get("instance_role") or "undeclared"
|
||||
label = health.get("instance_label")
|
||||
print(f"Instance: {role}{f' ({label})' if label else ''}")
|
||||
summary = _api_get("/state/summary")
|
||||
t = summary["totals"]
|
||||
print(f"Topics: {t['topics']['active']} active")
|
||||
print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked")
|
||||
print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked")
|
||||
print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated")
|
||||
topics = t.get("topics", {})
|
||||
workplans = t.get("workplans") or t.get("workstreams", {})
|
||||
tasks = t.get("tasks", {})
|
||||
decisions = t.get("decisions", {})
|
||||
print(f"API base: {API_BASE}")
|
||||
print(f"Topics: {topics.get('active', 0)} active")
|
||||
print(f"Workplans: {workplans.get('active', 0)} active, {workplans.get('blocked', 0)} blocked")
|
||||
# Task statuses are wait|todo|progress|done|cancel (see workplan-convention.md).
|
||||
print(f"Tasks: {tasks.get('progress', 0)} in-progress, {tasks.get('todo', 0)} todo, {tasks.get('wait', 0)} waiting")
|
||||
print(f"Decisions: {decisions.get('open', 0)} open, {decisions.get('escalated', 0)} escalated")
|
||||
blocking = summary.get("blocking_decisions", [])
|
||||
if blocking:
|
||||
print(f"\nBlocking decisions ({len(blocking)}):")
|
||||
|
|
@ -522,6 +583,73 @@ def cmd_status(_args: argparse.Namespace) -> None:
|
|||
print(f" [{deadline}] {d['title']}")
|
||||
|
||||
|
||||
def _load_json_file(path: str) -> dict:
|
||||
target = Path(path)
|
||||
try:
|
||||
value = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: invalid JSON file {target}: {exc}")
|
||||
sys.exit(2)
|
||||
if not isinstance(value, dict):
|
||||
print(f"ERROR: {target} must contain a JSON object")
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
def _git_source(path: str, repo_slug: str | None = None) -> tuple[Path, dict]:
|
||||
target = Path(path).resolve()
|
||||
try:
|
||||
root = Path(subprocess.check_output(
|
||||
["git", "-C", str(target.parent), "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
).strip())
|
||||
revision = subprocess.check_output(
|
||||
["git", "-C", str(root), "rev-parse", "HEAD"], text=True
|
||||
).strip()
|
||||
relative = target.relative_to(root).as_posix()
|
||||
except (subprocess.CalledProcessError, ValueError) as exc:
|
||||
print(f"ERROR: {target} must be inside a Git repository: {exc}")
|
||||
sys.exit(2)
|
||||
return target, {"repo": repo_slug or root.name, "path": relative, "revision": revision}
|
||||
|
||||
|
||||
def cmd_review_project(args: argparse.Namespace) -> None:
|
||||
"""Project a file-authoritative contract wrapper through the direct API."""
|
||||
target, source = _git_source(args.file, args.source_repo)
|
||||
contract = _load_json_file(str(target))
|
||||
payload = contract if "contract" in contract and "source" in contract else {
|
||||
"contract": contract,
|
||||
"source": source,
|
||||
"decision_id": args.decision_id,
|
||||
"workplan_id": args.workplan_id,
|
||||
"task_id": args.task_id,
|
||||
"required_for_decision": args.required_for_decision,
|
||||
}
|
||||
print(json.dumps(
|
||||
_api_post("/review-contracts/projections", payload),
|
||||
indent=2,
|
||||
))
|
||||
|
||||
|
||||
def cmd_review_submit(args: argparse.Namespace) -> None:
|
||||
"""Submit a file-authoritative receipt against an active contract."""
|
||||
key = urllib.parse.quote(args.contract_key, safe="")
|
||||
target, source = _git_source(args.file, args.source_repo)
|
||||
receipt = _load_json_file(str(target))
|
||||
if "source" not in receipt:
|
||||
receipt["source"] = source
|
||||
print(json.dumps(
|
||||
_api_post(f"/review-contracts/{key}/receipts", receipt),
|
||||
indent=2,
|
||||
))
|
||||
|
||||
|
||||
def cmd_review_status(args: argparse.Namespace) -> None:
|
||||
"""Print the derived owner and gate matrix; never an execution authorization."""
|
||||
key = urllib.parse.quote(args.contract_key, safe="")
|
||||
print(json.dumps(_api_get(f"/review-contracts/{key}/aggregate"), indent=2))
|
||||
|
||||
|
||||
|
||||
def _outbox_store(args):
|
||||
from api.edge.outbox import OutboxStore, default_outbox_path
|
||||
|
|
@ -601,6 +729,8 @@ def main() -> None:
|
|||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
configure_repo_commands(sub)
|
||||
|
||||
# register
|
||||
statehub_reg = sub.add_parser(
|
||||
"register",
|
||||
|
|
@ -608,6 +738,11 @@ def main() -> None:
|
|||
)
|
||||
statehub_reg.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)")
|
||||
statehub_reg.add_argument("--domain", default=None, help="State Hub domain slug")
|
||||
statehub_reg.add_argument(
|
||||
"--topic",
|
||||
default=None,
|
||||
help="Existing active topic slug within the selected domain",
|
||||
)
|
||||
statehub_reg.add_argument("--repo-slug", default=None, help="Repo slug (auto-detected if omitted)")
|
||||
statehub_reg.add_argument("--wp-prefix", default=None, help="Workplan prefix, e.g. STATE-WP")
|
||||
statehub_reg.add_argument("--description", default=None, help="One-sentence repo description")
|
||||
|
|
@ -673,6 +808,11 @@ def main() -> None:
|
|||
fix.add_argument("--remote", action="store_true", help="Pull before fixing; requires --repo or --all")
|
||||
fix.add_argument("--max-seconds", type=int, default=None, help="Wall-clock budget for --remote --all")
|
||||
fix.add_argument("--no-writeback", action="store_true", help="Disable DB-to-file status writeback")
|
||||
fix.add_argument(
|
||||
"--bootstrap-empty-projection",
|
||||
action="store_true",
|
||||
help="Rebuild file UUIDs only after proving the repo projection is empty",
|
||||
)
|
||||
fix.add_argument("--archive-closed", action="store_true", help="Archive closed root workplans after fixing")
|
||||
fix.add_argument("--archive-workplan", default=None, help="Archive only the matching workplan id or filename")
|
||||
fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed")
|
||||
|
|
@ -684,6 +824,32 @@ def main() -> None:
|
|||
help="Preserve checker exit code 2 for warnings-only runs",
|
||||
)
|
||||
|
||||
# quality-debt (STATE-WP-0077)
|
||||
qdebt = sub.add_parser(
|
||||
"quality-debt",
|
||||
help="List DoX quality debt: ready without DoR-Ok, finished without DoD-Ok, intakes without DoC-Ok",
|
||||
)
|
||||
qdebt.add_argument("--repo-path", default=None, help="Repo root (default: cwd)")
|
||||
qdebt.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
|
||||
qdebt.add_argument("--no-hub", action="store_true", help="Skip hub intake scan")
|
||||
qdebt.add_argument("--json", action="store_true", dest="as_json")
|
||||
qdebt.add_argument("--strict", action="store_true", help="Exit 1 if any debt found")
|
||||
qdebt.set_defaults(func=cmd_quality_debt)
|
||||
|
||||
# promote-intake
|
||||
promote = sub.add_parser(
|
||||
"promote-intake",
|
||||
help="Promote a routed intake into a workplan, task, decision, or engagement (CUST-WP-0061-T03)",
|
||||
)
|
||||
promote.add_argument("intake_id", help="UUID of the routed intake")
|
||||
promote.add_argument("--to", required=True, choices=["workplan", "task", "decision", "engagement"])
|
||||
promote.add_argument("--repo-path", default=os.getcwd(), help="Local checkout of the target repo")
|
||||
promote.add_argument("--repo-slug", required=True, help="Registered repo slug")
|
||||
promote.add_argument("--domain", required=True, help="Market domain slug, e.g. infotech")
|
||||
promote.add_argument("--target-file", default=None, help="Repo-relative path (required for decision/engagement)")
|
||||
promote.add_argument("--workplan-file", default=None, help="Repo-relative workplan file path (required for task)")
|
||||
promote.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
|
||||
|
||||
# create-workstream
|
||||
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
|
||||
cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under")
|
||||
|
|
@ -736,6 +902,26 @@ def main() -> None:
|
|||
# status
|
||||
sub.add_parser("status", help="Show State Hub health and summary totals")
|
||||
|
||||
# review — file-backed multi-owner review projection
|
||||
review = sub.add_parser("review", help="Project and inspect multi-owner review evidence")
|
||||
review_sub = review.add_subparsers(dest="review_command", required=True)
|
||||
review_project = review_sub.add_parser("project", help="Project an authoritative contract wrapper")
|
||||
review_project.add_argument("file")
|
||||
review_project.add_argument("--source-repo", default=None)
|
||||
review_project.add_argument("--decision-id", default=None)
|
||||
review_project.add_argument("--workplan-id", default=None)
|
||||
review_project.add_argument("--task-id", default=None)
|
||||
review_project.add_argument("--required-for-decision", action="store_true")
|
||||
review_project.set_defaults(func=cmd_review_project)
|
||||
review_submit = review_sub.add_parser("submit", help="Submit an authoritative receipt file")
|
||||
review_submit.add_argument("contract_key")
|
||||
review_submit.add_argument("file")
|
||||
review_submit.add_argument("--source-repo", default=None)
|
||||
review_submit.set_defaults(func=cmd_review_submit)
|
||||
review_status = review_sub.add_parser("status", help="Print owner and gate status")
|
||||
review_status.add_argument("contract_key")
|
||||
review_status.set_defaults(func=cmd_review_status)
|
||||
|
||||
# dev up — files-first local hub (CUST-WP-0054-T07)
|
||||
dev = sub.add_parser("dev", help="Local dev-hub commands")
|
||||
dev_sub = dev.add_subparsers(dest="dev_command", required=True)
|
||||
|
|
@ -767,6 +953,8 @@ def main() -> None:
|
|||
cmd_ingest_sbom(args)
|
||||
elif args.command == "fix-consistency":
|
||||
cmd_fix_consistency(args)
|
||||
elif args.command == "promote-intake":
|
||||
cmd_promote_intake(args)
|
||||
elif args.command == "create-workstream":
|
||||
cmd_create_workstream(args)
|
||||
elif args.command == "create-task":
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ const _configDir = dirname(fileURLToPath(import.meta.url));
|
|||
const _modalScript = readFileSync(
|
||||
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
|
||||
)
|
||||
.replace(/^export function /gm, "function ") // strip ES module export
|
||||
// Strip ES module exports so the file can run as a plain <script>.
|
||||
.replace(/^export function /gm, "function ")
|
||||
.replace(/^export const /gm, "const ")
|
||||
+ "\ninitImprovementModal();\n"; // auto-initialise
|
||||
|
||||
export default {
|
||||
|
|
@ -54,6 +56,8 @@ export default {
|
|||
pages: [
|
||||
{ name: "Repository DoI", path: "/policy/repo-doi" },
|
||||
{ name: "Service DoM", path: "/policy/service-dom" },
|
||||
{ name: "Intake DoC", path: "/policy/intake-doc" },
|
||||
{ name: "Work-item DoR", path: "/policy/work-item-dor" },
|
||||
{ name: "Workplan DoD", path: "/policy/workstream-dod" },
|
||||
],
|
||||
},
|
||||
|
|
@ -81,7 +85,6 @@ export default {
|
|||
{ name: "Interventions", path: "/interventions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "UI Feedback", path: "/ui-feedback" },
|
||||
{ name: "Suggestions", path: "/suggestions" },
|
||||
{ name: "WSJF Triage", path: "/wsjf-triage" },
|
||||
],
|
||||
},
|
||||
|
|
@ -102,6 +105,7 @@ export default {
|
|||
{ name: "Domains", path: "/docs/domains" },
|
||||
{ name: "Goals", path: "/docs/goals" },
|
||||
{ name: "Extension Points", path: "/docs/extensions" },
|
||||
{ name: "Intakes", path: "/docs/intakes" },
|
||||
{ name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" },
|
||||
{ name: "Interventions", path: "/docs/interventions" },
|
||||
{ name: "Live Data", path: "/docs/live-data" },
|
||||
|
|
@ -115,15 +119,16 @@ export default {
|
|||
{ name: "SBOM", path: "/docs/sbom" },
|
||||
{ name: "SCOPE.md", path: "/docs/scope" },
|
||||
{ name: "Service Catalog", path: "/docs/services" },
|
||||
{ name: "Suggestions (archived)", path: "/docs/suggestions" },
|
||||
{ name: "Tasks", path: "/docs/tasks" },
|
||||
{ name: "TPSC", path: "/docs/tpsc" },
|
||||
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
|
||||
{ name: "Technical Debt", path: "/docs/debt" },
|
||||
{ name: "Todo", path: "/docs/todo" },
|
||||
{ name: "Work Records", path: "/docs/work-records" },
|
||||
{ name: "Workplan Health", path: "/docs/workstream-health-index" },
|
||||
{ name: "Workplan Lifecycle", path: "/docs/workstream-lifecycle" },
|
||||
{ name: "Workplans", path: "/docs/workstreams" },
|
||||
{ name: "Suggestions", path: "/docs/suggestions" },
|
||||
{ name: "WSJF Triage", path: "/docs/wsjf-triage" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,10 +12,59 @@
|
|||
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
|
||||
*
|
||||
* Interaction:
|
||||
* - Hold Shift → cursor changes to crosshair across the entire page
|
||||
* - Hold Shift 1s → cursor changes to copy/highlight mode (shift-wait-click)
|
||||
* - Shift+click any element (except form controls) → opens suggestion modal
|
||||
*/
|
||||
|
||||
/** Domain slug for dashboard-improvement TD records (state-hub lives under infotech). */
|
||||
export const DEFAULT_IMPROVEMENT_DOMAIN = "infotech";
|
||||
const DEFAULT_API = "http://127.0.0.1:8000";
|
||||
const API_STORAGE_KEY = "stateHubApiBase";
|
||||
|
||||
/**
|
||||
* Resolve API base when init is called without an explicit apiBase.
|
||||
* Mirrors dashboard/src/components/config.js so the modal works when the
|
||||
* dashboard is not on 127.0.0.1 (remote host, tunnel, storage override).
|
||||
*/
|
||||
export function resolveImprovementApiBase({
|
||||
location = globalThis.location,
|
||||
storage = globalThis.localStorage,
|
||||
} = {}) {
|
||||
const clean = (value) => {
|
||||
if (typeof value !== "string") return null;
|
||||
const cleaned = value.trim().replace(/\/+$/, "");
|
||||
return cleaned || null;
|
||||
};
|
||||
try {
|
||||
if (location?.href) {
|
||||
const url = new URL(location.href);
|
||||
for (const name of ["api_base", "apiBase"]) {
|
||||
const q = clean(url.searchParams.get(name));
|
||||
if (q) return q;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const fromGlobal = clean(globalThis.STATE_HUB_API_BASE);
|
||||
if (fromGlobal) return fromGlobal;
|
||||
try {
|
||||
const fromStorage = clean(storage?.getItem?.(API_STORAGE_KEY));
|
||||
if (fromStorage) return fromStorage;
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
if (location?.href) {
|
||||
const url = new URL(location.href);
|
||||
if (!["http:", "https:"].includes(url.protocol)) return DEFAULT_API;
|
||||
if (url.hostname === "::1" || url.hostname === "[::1]") return DEFAULT_API;
|
||||
url.port = globalThis.STATE_HUB_API_PORT || "8000";
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.origin;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_API;
|
||||
}
|
||||
|
||||
const _STYLE_ID = "improvement-modal-styles";
|
||||
|
||||
function _ensureStyles() {
|
||||
|
|
@ -239,10 +288,13 @@ let _initialized = false;
|
|||
* Safe to call multiple times — only the first call takes effect.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiBase State Hub API base URL (default: "http://127.0.0.1:8000")
|
||||
* @param {string} opts.domain Domain slug for the TD record (default: "custodian")
|
||||
* @param {string} [opts.apiBase] State Hub API base URL (resolved like config.js if omitted)
|
||||
* @param {string} [opts.domain] Domain slug for the TD record (default: "infotech")
|
||||
*/
|
||||
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
|
||||
export function initImprovementModal({
|
||||
apiBase = resolveImprovementApiBase(),
|
||||
domain = DEFAULT_IMPROVEMENT_DOMAIN,
|
||||
} = {}) {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_ensureStyles();
|
||||
|
|
@ -391,7 +443,10 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
|
|||
try {
|
||||
const r = await fetch(`${apiBase}/technical-debt/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-StateHub-Component": "state-hub.dashboard.improvement-modal",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.ok) {
|
||||
|
|
@ -400,7 +455,13 @@ export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain
|
|||
} else {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Submit suggestion";
|
||||
_toast(`⚠ Submission failed (HTTP ${r.status})`);
|
||||
let detail = `HTTP ${r.status}`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (typeof body?.detail === "string") detail = body.detail;
|
||||
else if (body?.detail != null) detail = JSON.stringify(body.detail);
|
||||
} catch { /* keep status-only detail */ }
|
||||
_toast(`⚠ Submission failed (${detail})`);
|
||||
}
|
||||
} catch {
|
||||
submitBtn.disabled = false;
|
||||
|
|
|
|||
22
dashboard/src/components/workplan-search.js
Normal file
22
dashboard/src/components/workplan-search.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Case-insensitive substring match for Overview "Workplans by Repository".
|
||||
* Matches title, repo, domain, filename, owner, and id.
|
||||
*
|
||||
* @param {object} workplan Row from overview workplan_rows
|
||||
* @param {string} query Free-text filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function workplanMatchesSearch(workplan, query) {
|
||||
const q = String(query ?? "").trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
if (!workplan || typeof workplan !== "object") return false;
|
||||
const haystack = [
|
||||
workplan.title,
|
||||
workplan.repo_label,
|
||||
workplan.domain,
|
||||
workplan.workplan_filename,
|
||||
workplan.owner,
|
||||
workplan.id,
|
||||
].filter(Boolean).join("\n").toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}
|
||||
|
|
@ -330,7 +330,13 @@ if (filtered.length === 0) {
|
|||
} else {
|
||||
display(html`<div class="dec-list">${filtered.map(d => {
|
||||
const border = STATUS_BORDER[d.status] ?? "#ccc";
|
||||
const snippet = (d.description || d.rationale || "").slice(0, 200);
|
||||
const _desc = d.description || "";
|
||||
const _rat = d.rationale || "";
|
||||
const _primary = _desc || _rat;
|
||||
const snippet = _primary.slice(0, 200);
|
||||
// Expandable when text is truncated, or when both fields exist (the
|
||||
// collapsed view only shows one of them).
|
||||
const _needsExpand = _primary.length > 200 || (_desc && _rat && _desc !== _rat);
|
||||
const due = fmtDate(d.deadline);
|
||||
const decided = fmtDate(d.decided_at);
|
||||
const overdue = isOverdue(d.deadline);
|
||||
|
|
@ -375,7 +381,18 @@ if (filtered.length === 0) {
|
|||
${_isOpen ? html`<button class="dec-resolve-btn" onclick=${onResolve}>Resolve</button>` : ""}
|
||||
</div>
|
||||
<div class="dec-title">${d.title}</div>
|
||||
${snippet ? html`<div class="dec-snippet">${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}</div>` : ""}
|
||||
${_primary ? (_needsExpand
|
||||
? html`<details class="dec-expand">
|
||||
<summary>
|
||||
<span class="dec-snippet dec-snippet-collapsed">${snippet}${_primary.length > 200 ? "…" : ""}</span>
|
||||
<span class="dec-more"></span>
|
||||
</summary>
|
||||
<div class="dec-fulltext">
|
||||
${_desc ? html`<div class="dec-fulltext-section"><div class="dec-fulltext-label">Description</div><div class="dec-snippet">${_desc}</div></div>` : ""}
|
||||
${_rat ? html`<div class="dec-fulltext-section"><div class="dec-fulltext-label">Rationale</div><div class="dec-snippet">${_rat}</div></div>` : ""}
|
||||
</div>
|
||||
</details>`
|
||||
: html`<div class="dec-snippet">${_primary}</div>`) : ""}
|
||||
${d.decided_by ? html`<div class="dec-resolved-by">✓ ${d.decided_by}${decided ? " · " + decided : ""}</div>` : ""}
|
||||
${d.escalation_note && !["resolved", "superseded"].includes(d.status) ? html`<div class="dec-escalation-note">${d.escalation_note}</div>` : ""}
|
||||
</div>`;
|
||||
|
|
@ -458,6 +475,18 @@ if (escalated.length > 0) {
|
|||
.dec-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.2rem; }
|
||||
.dec-snippet { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.45; white-space: pre-wrap; }
|
||||
.dec-resolved-by { font-size: 0.78rem; color: #22c55e; margin-top: 0.3rem; }
|
||||
|
||||
/* ── Expandable full text ─────────────────────────────────────────────────── */
|
||||
.dec-expand summary { list-style: none; cursor: pointer; }
|
||||
.dec-expand summary::-webkit-details-marker { display: none; }
|
||||
.dec-expand[open] .dec-snippet-collapsed { display: none; }
|
||||
.dec-more::after { content: "Show full text ▾"; }
|
||||
.dec-expand[open] .dec-more::after { content: "Show less ▴"; }
|
||||
.dec-more { display: inline-block; margin-left: 0.35rem; font-size: 0.72rem; font-weight: 600; color: steelblue; white-space: nowrap; }
|
||||
.dec-more:hover { text-decoration: underline; }
|
||||
.dec-expand[open] .dec-more { display: block; margin: 0.15rem 0 0.35rem; }
|
||||
.dec-fulltext-section + .dec-fulltext-section { margin-top: 0.5rem; }
|
||||
.dec-fulltext-label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa); margin-bottom: 0.1rem; }
|
||||
.dec-escalation-note { font-size: 0.78rem; color: #b45309; margin-top: 0.3rem; background: #fef3c7; border-radius: 4px; padding: 0.25rem 0.5rem; }
|
||||
|
||||
/* ── Resolve button ───────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -227,8 +227,9 @@ and age in days.
|
|||
---
|
||||
|
||||
*Capability requests are a sanctioned write use case of the State Hub alongside
|
||||
`resolve_decision`, `get_next_steps`, and the suggestion backlog writes
|
||||
(`create_suggestion`, `vet_suggestion`, `decline_suggestion`,
|
||||
`promote_suggestion_to_task`, `bump_suggestion_relevance`). They do not
|
||||
originate in workplan files —
|
||||
they are operational coordination.*
|
||||
`resolve_decision`, `get_next_steps`, and intake capture (`create_intake`,
|
||||
`route_intake`, `close_intake`; promotion via `statehub promote-intake`). The
|
||||
legacy suggestion mutation tools (`create_suggestion`, `vet_suggestion`, …)
|
||||
return HTTP 410. Capability requests do not originate in workplan files —
|
||||
they are operational coordination. See [Work Records](/docs/work-records) and
|
||||
[Intakes](/docs/intakes).*
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ title: Contributions — Reference
|
|||
|
||||
# Contributions — Reference
|
||||
|
||||
Contributions track **outbound upstream work** — things the Custodian has
|
||||
identified that belong in a repo it does not own or control. Each contribution
|
||||
is a structured artifact filed locally in the repo's `contrib/` directory and
|
||||
registered in the state hub so it is never lost.
|
||||
Contributions track **outbound upstream work** — things identified for a
|
||||
repository the fleet does not own or control. Each contribution is a
|
||||
structured artifact filed locally under `contrib/` and registered in State Hub
|
||||
so it is not lost.
|
||||
|
||||
Contributions are **not** a work-record kind in the fleet registry
|
||||
(`work-record-types_v0.1.md`). They are a separate hub entity and dashboard
|
||||
surface used for third-party coordination. Work-record kinds cover internal
|
||||
coordination (workplan, task, intake, decision, engagement, register-entry).
|
||||
See [Work Records](/docs/work-records).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -15,22 +21,26 @@ registered in the state hub so it is never lost.
|
|||
|
||||
| Type | Full name | Use when |
|
||||
|------|-----------|----------|
|
||||
| `br` | Bug Report | You found a defect in an upstream tool or library |
|
||||
| `fr` | Feature Request | You need functionality that upstream does not yet provide |
|
||||
| `ep` | Extension Point | You identified a future enhancement opportunity in upstream code |
|
||||
| `upr` | Upstream PR | You have written (or are writing) a patch for an upstream repo |
|
||||
| `br` | Bug Report | Defect in an upstream tool or library |
|
||||
| `fr` | Feature Request | Functionality upstream does not yet provide |
|
||||
| `ep` | Extension Point | Future enhancement opportunity in upstream code |
|
||||
| `upr` | Upstream PR | Patch written (or being written) for an upstream repo |
|
||||
|
||||
---
|
||||
|
||||
## Flow workstations
|
||||
## Lifecycle (workstations)
|
||||
|
||||
```
|
||||
Contribution status is evaluated by the task-flow engine
|
||||
(`flows/contribution.yaml`). The stored field is `status`; flow tooling may
|
||||
call the same value a **workstation**.
|
||||
|
||||
```text
|
||||
draft → submitted → acknowledged → accepted → merged
|
||||
↘ ↘
|
||||
rejected withdrawn
|
||||
```
|
||||
|
||||
| Workstation | Meaning |
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **draft** | Artifact written locally; not yet sent upstream |
|
||||
| **submitted** | Filed as a GitHub issue, PR, or email — awaiting upstream response |
|
||||
|
|
@ -40,36 +50,42 @@ draft → submitted → acknowledged → accepted → merged
|
|||
| **rejected** | Upstream declined; record kept for future reference |
|
||||
| **withdrawn** | We decided not to pursue it |
|
||||
|
||||
Contribution movement is evaluated by the task-flow engine. The API asks
|
||||
whether the target workstation is reachable from the current information
|
||||
object; if not, it returns structured blocking assertions instead of a generic
|
||||
"transition not allowed" message. `submitted_at` is stamped automatically when
|
||||
the contribution reaches `submitted`; `resolved_at` is stamped when it reaches
|
||||
`merged`, `rejected`, or `withdrawn`.
|
||||
Reachability is assertion-based. For example, `rejected` is only enterable from
|
||||
`submitted` or `acknowledged`; `merged` only from `accepted`. If a target is
|
||||
not reachable, the API returns structured blocking assertions rather than a
|
||||
generic “transition not allowed” message.
|
||||
|
||||
Timestamps: `submitted_at` is stamped when status becomes `submitted`;
|
||||
`resolved_at` is stamped when status becomes `merged`, `rejected`, or
|
||||
`withdrawn` (and related terminal paths as implemented on the status patch).
|
||||
|
||||
---
|
||||
|
||||
## Relation to the Todo classification
|
||||
|
||||
Contributions map directly to the **Third-party** class in the inter-repo
|
||||
communication taxonomy:
|
||||
Contributions map to the **Third-party** class in the inter-repo communication
|
||||
taxonomy:
|
||||
|
||||
| Todo class | Mechanism |
|
||||
|------------|-----------|
|
||||
| Internal | Workplan file + task in this repo's workplan |
|
||||
| Internal | Workplan file + task in this repo |
|
||||
| Ecosystem | State hub task with `[repo:<slug>]` prefix |
|
||||
| **Third-party** | **Contribution artifact in `contrib/` + state hub registration** |
|
||||
| **Third-party** | **Contribution artifact in `contrib/` + hub registration** |
|
||||
|
||||
Contributions in `draft`, `submitted`, or `acknowledged` workstations appear as
|
||||
open Third-party todos on the [Todo](/todo) page.
|
||||
Open third-party items are contributions in `draft`, `submitted`, or
|
||||
`acknowledged`. They appear on the [Todo](/todo) page until the upstream loop
|
||||
closes (`merged`, `rejected`, or `withdrawn`).
|
||||
|
||||
Intakes are **not** third-party contributions: an intake is internal capture of
|
||||
a spark; a contribution is outbound work aimed at someone else’s repo.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
Each artifact lives in the current repo under `contrib/`:
|
||||
Each artifact lives in the **current** repo under `contrib/`:
|
||||
|
||||
```
|
||||
```text
|
||||
contrib/
|
||||
bug-reports/ br-YYYY-MM-DD--<org>--<repo>--<slug>.md
|
||||
feature-requests/ fr-YYYY-MM-DD--<org>--<repo>--<slug>.md
|
||||
|
|
@ -77,8 +93,8 @@ contrib/
|
|||
upstream-prs/ upr-YYYY-MM-DD--<org>--<repo>--<slug>.md
|
||||
```
|
||||
|
||||
Templates live in `~/the-custodian/canon/standards/contrib-templates/`.
|
||||
Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
|
||||
Templates: `~/the-custodian/canon/standards/contrib-templates/`.
|
||||
Convention: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -86,38 +102,52 @@ Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.
|
|||
|
||||
**1. Write the artifact file** using the appropriate template.
|
||||
|
||||
**2. Register it in the state hub** via MCP:
|
||||
**2. Register it in the state hub** (MCP):
|
||||
|
||||
```
|
||||
```text
|
||||
register_contribution(
|
||||
type = "fr",
|
||||
title = "Add sidebar TOC injection API",
|
||||
target_org = "observablehq",
|
||||
target_repo = "framework",
|
||||
body_path = "contrib/feature-requests/fr-2026-02-26--observablehq--framework--toc.md",
|
||||
related_workstream_id = "<uuid>"
|
||||
type = "fr",
|
||||
title = "Add sidebar TOC injection API",
|
||||
target_org = "observablehq",
|
||||
target_repo = "framework",
|
||||
body_path = "contrib/feature-requests/fr-2026-02-26--observablehq--framework--toc.md",
|
||||
related_workplan_id = "<uuid>" # preferred; related_workstream_id still accepted as alias
|
||||
)
|
||||
```
|
||||
|
||||
**3. Close the loop** when you file it upstream:
|
||||
REST: `POST /contributions/` with the same fields.
|
||||
|
||||
```
|
||||
**3. Close the loop** when filed upstream — advance via flow tooling or status
|
||||
patch:
|
||||
|
||||
```text
|
||||
advance_workstation(entity_type="contribution", entity_id="<uuid>", target_workstation="submitted")
|
||||
```
|
||||
|
||||
**4. Keep updating** as upstream responds — `acknowledged`, `accepted`, `merged`.
|
||||
```bash
|
||||
curl -X PATCH "http://127.0.0.1:8000/contributions/<uuid>/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "submitted"}'
|
||||
```
|
||||
|
||||
**4. Keep updating** as upstream responds — `acknowledged`, `accepted`,
|
||||
`merged` (or `rejected` / `withdrawn`).
|
||||
|
||||
Optional links: `related_topic_id`, `related_workplan_id`, `repo_id` (managed
|
||||
repo that owns the local artifact).
|
||||
|
||||
---
|
||||
|
||||
## Kanban board
|
||||
|
||||
The Contributions page groups artifacts by status column. Only columns with at
|
||||
least one entry are shown. The **⚠ follow-up banner** appears when any
|
||||
least one entry are shown. The **follow-up banner** appears when any
|
||||
contribution has been in `submitted` or `acknowledged` for an extended period
|
||||
without further movement — a prompt to check in with upstream.
|
||||
|
||||
Data source: `GET /contributions/`.
|
||||
|
||||
---
|
||||
|
||||
*Contributions are append-only. Rejected or withdrawn artifacts are retained as
|
||||
institutional memory — they explain why certain approaches were tried and
|
||||
dropped.*
|
||||
*Contributions are retained after rejection or withdrawal — institutional
|
||||
memory for why certain approaches were tried and dropped.*
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ The Goals page groups everything by domain:
|
|||
|
||||
Workplans carry an optional `repo_goal_id` field. Setting it traces *why* a workplan exists — which specific repo goal it contributes to. This connection is currently recorded in the DB but is not yet visualised in the Workplans page.
|
||||
|
||||
To set the link when creating a workplan through the preferred API, pass `repo_goal_id`. To update an existing one, use `PATCH /workplans/{id}/` with `{"repo_goal_id": "<uuid>"}`. Legacy `create_workstream` and `/workstreams/{id}/` callers remain compatibility-supported while they are metered.
|
||||
To set the link when creating a workplan through the preferred API, pass `repo_goal_id`. To update an existing one, use `PATCH /workplans/{id}/` with `{"repo_goal_id": "<uuid>"}`. The legacy `create_workstream` tool is removed and `/workstreams/{id}` returns 410; use `create_workplan` and `/workplans/{id}`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
185
dashboard/src/docs/intakes.md
Normal file
185
dashboard/src/docs/intakes.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
---
|
||||
title: Intakes — Reference
|
||||
---
|
||||
|
||||
# Intakes — Reference
|
||||
|
||||
An **intake** is a work-record kind for a **spark**: an idea, finding,
|
||||
directive, or request that is not yet a workplan, task, decision, or
|
||||
engagement. Intakes hold early signal until someone vets, routes, and either
|
||||
promotes or closes them.
|
||||
|
||||
Canon: `the-custodian/canon/standards/work-record-types_v0.1.md`
|
||||
(`kind: intake`, id scheme `{PREFIX}-IN-NNNN`).
|
||||
Umbrella: [Work Records](/docs/work-records).
|
||||
|
||||
Intakes **replace** the retired hub **suggestion** backlog for new work. See
|
||||
[Suggestions (legacy)](/docs/suggestions).
|
||||
|
||||
**Quality:** discovery comprehension is governed by
|
||||
[Definition of Comprehension (DoC)](/policy/intake-doc) (`DoC-Ok` /
|
||||
`DoC-Failed` / unassessed). DoC is independent of lifecycle `status` and is
|
||||
**not** implementation readiness ([DoR](/policy/work-item-dor)). Model:
|
||||
`docs/work-record-quality-gates.md`.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```text
|
||||
open -> vetted -> routed -> closed(promoted | declined | absorbed)
|
||||
```
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **open** | Captured; not yet fully vetted |
|
||||
| **vetted** | Reviewed; structure and intent are clear enough to route |
|
||||
| **routed** | Assigned a destination / owner path; eligible for promotion |
|
||||
| **closed** | Terminal; requires an **outcome** |
|
||||
|
||||
| Outcome (only when `closed`) | Meaning |
|
||||
|------------------------------|---------|
|
||||
| **promoted** | Became another work record; `promoted_to` holds the new canonical id |
|
||||
| **declined** | Explicitly not pursued |
|
||||
| **absorbed** | Already covered by existing work; no new record needed |
|
||||
|
||||
Database constraints (what the API enforces):
|
||||
|
||||
- At least one of `topic_id`, `workplan_id`, or `repo_id` is set (scope).
|
||||
- `closed` requires `outcome`.
|
||||
- `outcome = promoted` requires `promoted_to`.
|
||||
|
||||
Route action: only from `open` or `vetted` → `routed`.
|
||||
Close action: from `open`, `vetted`, or `routed` → `closed`.
|
||||
|
||||
---
|
||||
|
||||
## Fields that matter
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
| `title` / `description` | Human-readable content |
|
||||
| `lane` | Autonomy lane: `green` \| `blue` \| `yellow` \| `orange` \| `red` |
|
||||
| `status` / `outcome` | Lifecycle |
|
||||
| `origin` / `origin_ref` | Where the spark came from. **Residuals:** `origin: residual` (or `handoff`) and `origin_ref: <parent-WP-id>` (e.g. `BINKY-WP-0006`). Also e.g. `legacy-suggestion:<uuid>`, mail-triage ids |
|
||||
| `promoted_to` | Canonical id of the record this became after promotion |
|
||||
| `source_repo_path` | Repo-relative path of the YAML block / file when file-authored |
|
||||
| `routed_note` | Free-text routing context |
|
||||
| `notes` | Append-only note list via `POST /intakes/{id}/notes` |
|
||||
|
||||
Identity: hub rows use UUIDv7 primary keys. File-backed blocks use fleet ids
|
||||
such as `CUST-IN-0004` and receive `state_hub_intake_id` write-back from
|
||||
`fix-consistency`.
|
||||
|
||||
---
|
||||
|
||||
## How intakes enter the hub
|
||||
|
||||
**1. File-backed (preferred for lasting work)**
|
||||
|
||||
Author a schema-valid YAML block in any repo markdown file (canon schemas under
|
||||
`the-custodian/canon/standards/schemas/work-records/`). Commit, then run
|
||||
`statehub fix-consistency` so the hub registers the record and writes the UUID
|
||||
back.
|
||||
|
||||
**2. Hub API / MCP (capture path)**
|
||||
|
||||
```text
|
||||
create_intake(title=..., topic_id=... | workplan_id=... | repo_id=..., lane="green", ...)
|
||||
# REST: POST /intakes/
|
||||
```
|
||||
|
||||
```text
|
||||
list_intakes(status="open")
|
||||
route_intake(intake_id, routed_note=...)
|
||||
close_intake(intake_id, outcome="declined"|"absorbed"|"promoted", promoted_to=...)
|
||||
```
|
||||
|
||||
REST mirrors: `GET/POST /intakes/`, `PATCH /intakes/{id}`,
|
||||
`POST /intakes/{id}/route`, `POST /intakes/{id}/close`,
|
||||
`POST /intakes/{id}/notes`.
|
||||
|
||||
---
|
||||
|
||||
## Promotion (intake → another kind)
|
||||
|
||||
Promotion is the sanctioned path from a **routed** intake into a durable
|
||||
work-record kind:
|
||||
|
||||
```text
|
||||
intake.routed → workplan | task | decision | engagement
|
||||
```
|
||||
|
||||
Implemented by:
|
||||
|
||||
```bash
|
||||
# via CLI (statehub entrypoint wraps scripts/promote_intake.py)
|
||||
statehub promote-intake <intake-id-or-uuid> --to workplan --repo <path>
|
||||
statehub promote-intake <id> --to task --workplan-file workplans/….md
|
||||
statehub promote-intake <id> --to decision --target-file <md>
|
||||
statehub promote-intake <id> --to engagement --target-file <md>
|
||||
```
|
||||
|
||||
What the promotion transition does today:
|
||||
|
||||
1. Validates the intake is **routed**.
|
||||
2. Writes the target artefact (ADR-001 workplan file, task block, or
|
||||
decision/engagement YAML block).
|
||||
3. Sets intake `promoted_to` and closes with `outcome=promoted`.
|
||||
4. Sets `origin: intake:<…>` on the new record for back-link.
|
||||
5. Registers / syncs with the hub where a hub entity exists.
|
||||
|
||||
**Engagements:** promotion can write the file block; a first-class hub
|
||||
engagement entity is not present yet — the CLI reports that limitation rather
|
||||
than inventing silent hub rows.
|
||||
|
||||
Closing with `outcome=promoted` by hand is supported for after-the-fact
|
||||
recording, but the promote CLI is the intended path for both artefacts.
|
||||
|
||||
---
|
||||
|
||||
## What is not wired yet
|
||||
|
||||
These are present limitations of the current hub surface (not aspirations):
|
||||
|
||||
| Area | Current behaviour |
|
||||
|------|-------------------|
|
||||
| **Dashboard** | No dedicated Intakes page yet; legacy [Suggestions](/suggestions) still reads the old table |
|
||||
| **Next-steps / summary** | `ranked_suggestions` and open-suggestion signals in `GET /state/summary` and `get_next_steps` still read the **suggestion** table, not intakes |
|
||||
| **WSJF on intakes** | Suggestion WSJF fields (`base_value`, `relevance`, …) are not part of the intake model |
|
||||
| **Task-flow YAML** | Workplan, task, and contribution have `flows/*.yaml`; intake advances via dedicated route/close endpoints rather than `advance_workstation` |
|
||||
|
||||
Use `GET /intakes/` or MCP `list_intakes` for the live backlog.
|
||||
|
||||
---
|
||||
|
||||
## Residuals as intakes
|
||||
|
||||
When a workplan finishes with leftover work that is not large enough for an
|
||||
immediate child workplan, author an **intake** with:
|
||||
|
||||
```yaml
|
||||
origin: residual
|
||||
origin_ref: BINKY-WP-0006 # parent workplan id
|
||||
lane: green # or blue/yellow/…
|
||||
status: open # or vetted/routed when ready
|
||||
```
|
||||
|
||||
Promote with `statehub promote-intake` when structure is ready (e.g. to a
|
||||
workplan). Listing open residual intakes across domains is a State Hub read
|
||||
concern (`list_intakes` + origin filters; planned `statehub residuals` CLI).
|
||||
See [Work Records — Residuals](/docs/work-records#residuals-role-not-kind).
|
||||
|
||||
## Relation to other kinds
|
||||
|
||||
| Kind | Boundary |
|
||||
|------|----------|
|
||||
| **workplan / task** | Durable planned work; intake is the pre-plan capture form |
|
||||
| **decision** | Approval package; an intake may promote *into* a decision |
|
||||
| **contribution** | Outbound third-party artefact — different entity; not an intake outcome |
|
||||
| **suggestion (legacy)** | Superseded capture mechanism; do not create new ones |
|
||||
| **residual** | Not a kind — role of intake/WP/decision with `origin: residual` |
|
||||
|
||||
---
|
||||
|
||||
*Intakes are a fresh entity (CUST-WP-0061), not a rename of `suggestions`.*
|
||||
|
|
@ -24,6 +24,13 @@ by repository. Each bar is broken into four task-status segments:
|
|||
| orange | wait |
|
||||
| light grey | todo |
|
||||
|
||||
Above the chart:
|
||||
|
||||
- **Mode selector** — lifecycle, health, or recently-changed windows (with
|
||||
matching workplan counts in each option).
|
||||
- **Text filter** — case-insensitive substring match on title, repository,
|
||||
domain, workplan filename, and owner. Useful when many rows share a mode.
|
||||
|
||||
The left axis shows the `domain / repository` label once per repository group.
|
||||
The `done/total` count is printed to the right of each bar. Workplans with no
|
||||
tasks yet show a grey "— no tasks yet" label.
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ cd ~/ralph-workplan && ./install.sh --uninstall
|
|||
|
||||
```markdown
|
||||
---
|
||||
id: WP-0001
|
||||
id: EXAMPLE-WP-0001
|
||||
title: "Build a thing"
|
||||
status: ready
|
||||
---
|
||||
|
|
@ -100,7 +100,7 @@ Optional description.
|
|||
## Task: Do the first thing
|
||||
|
||||
```task
|
||||
id: T-01
|
||||
id: EXAMPLE-WP-0001-T01
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
|
@ -108,7 +108,7 @@ priority: high
|
|||
## Task: Do the second thing
|
||||
|
||||
```task
|
||||
id: T-02
|
||||
id: EXAMPLE-WP-0001-T02
|
||||
status: todo
|
||||
priority: medium
|
||||
```
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue