generated from coulomb/repo-seed
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
15
dashboard/src/data/domains.json.py
Normal file
15
dashboard/src/data/domains.json.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches /domains/ from the API."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/domains/?status=all", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "domains": []}))
|
||||
15
dashboard/src/data/repos.json.py
Normal file
15
dashboard/src/data/repos.json.py
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches /repos/ from the API."""
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/repos/", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e), "repos": []}))
|
||||
149
dashboard/src/domains.md
Normal file
149
dashboard/src/domains.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: Domains
|
||||
---
|
||||
|
||||
```js
|
||||
const API = "http://127.0.0.1:8000";
|
||||
const POLL = 15_000;
|
||||
```
|
||||
|
||||
```js
|
||||
const domainsState = (async function*() {
|
||||
while (true) {
|
||||
let domains = [], repos = [], ok = false;
|
||||
try {
|
||||
const [rd, rr] = await Promise.all([
|
||||
fetch(`${API}/domains/?status=all`),
|
||||
fetch(`${API}/repos/`),
|
||||
]);
|
||||
ok = rd.ok && rr.ok;
|
||||
if (ok) {
|
||||
[domains, repos] = await Promise.all([rd.json(), rr.json()]);
|
||||
}
|
||||
} catch {}
|
||||
yield {domains, repos, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const domains = domainsState.domains ?? [];
|
||||
const repos = domainsState.repos ?? [];
|
||||
const _ok = domainsState.ok ?? false;
|
||||
const _ts = domainsState.ts;
|
||||
```
|
||||
|
||||
# Domains
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {openEntityModal} from "./components/entity-modal.js";
|
||||
|
||||
// ── Live indicator ─────────────────────────────────────────────────────────────
|
||||
const _liveEl = html`<div class="live-indicator">
|
||||
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
||||
${_ok
|
||||
? `Live · updated ${_ts?.toLocaleTimeString()}`
|
||||
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
|
||||
</div>`;
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
|
||||
// ── KPI row ────────────────────────────────────────────────────────────────────
|
||||
const activeDomains = domains.filter(d => d.status === "active");
|
||||
const archivedDomains = domains.filter(d => d.status === "archived");
|
||||
const newestDomain = [...domains].sort((a, b) => b.created_at?.localeCompare(a.created_at ?? "") ?? 0)[0];
|
||||
|
||||
display(html`<div class="kpi-row-top">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card-value">${domains.length}</div>
|
||||
<div class="kpi-card-label">total domains</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card-value">${activeDomains.length}</div>
|
||||
<div class="kpi-card-label">active</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card-value">${repos.length}</div>
|
||||
<div class="kpi-card-label">total repos</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card-value">${newestDomain?.name ?? "—"}</div>
|
||||
<div class="kpi-card-label">newest domain</div>
|
||||
</div>
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Domain Cards
|
||||
|
||||
```js
|
||||
// Build repo index by domain_id
|
||||
const reposByDomain = {};
|
||||
for (const repo of repos) {
|
||||
if (!reposByDomain[repo.domain_id]) reposByDomain[repo.domain_id] = [];
|
||||
reposByDomain[repo.domain_id].push(repo);
|
||||
}
|
||||
|
||||
if (domains.length === 0) {
|
||||
display(html`<p class="dim">No domains found. API may be offline.</p>`);
|
||||
} else {
|
||||
display(html`<div class="domain-grid">${domains.map(d => {
|
||||
const domainRepos = reposByDomain[d.id] ?? [];
|
||||
return html`<div class="domain-card domain-status-${d.status} entity-row"
|
||||
title="Click to view details">
|
||||
<div class="domain-card-header">
|
||||
<span class="domain-slug">${d.slug}</span>
|
||||
<span class="domain-status-badge domain-status-badge-${d.status}">${d.status}</span>
|
||||
</div>
|
||||
<div class="domain-name">${d.name}</div>
|
||||
${d.description ? html`<div class="domain-desc">${d.description.slice(0, 160)}${d.description.length > 160 ? " …" : ""}</div>` : ""}
|
||||
<div class="domain-repos">
|
||||
${domainRepos.length === 0
|
||||
? html`<span class="no-repos">no repos registered</span>`
|
||||
: domainRepos.map(r => html`<div class="repo-row">
|
||||
<span class="repo-name">${r.name}</span>
|
||||
${r.local_path ? html`<code class="repo-path">${r.local_path}</code>` : ""}
|
||||
${r.remote_url ? html`<a class="repo-url" href=${r.remote_url} target="_blank">${r.remote_url.replace(/^https?:\/\//, "")}</a>` : ""}
|
||||
</div>`)
|
||||
}
|
||||
</div>
|
||||
</div>`;
|
||||
})}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
/* ── Live indicator ───────────────────────────────────────────────────────── */
|
||||
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
|
||||
/* ── KPI row ─────────────────────────────────────────────────────────────── */
|
||||
.kpi-row-top { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.kpi-card { background: var(--theme-background-alt); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1.25rem; min-width: 120px; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.kpi-card-value { font-size: 1.6rem; font-weight: 700; line-height: 1.2; }
|
||||
.kpi-card-label { font-size: 0.72rem; color: var(--theme-foreground-muted, #888); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.2rem; }
|
||||
|
||||
/* ── Domain grid ─────────────────────────────────────────────────────────── */
|
||||
.domain-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.domain-card { border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 1rem 1.2rem; background: var(--theme-background-alt); }
|
||||
.domain-card.entity-row { cursor: default; }
|
||||
.domain-status-archived { opacity: 0.6; }
|
||||
.domain-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
|
||||
.domain-slug { font-family: monospace; font-size: 0.8rem; color: var(--theme-foreground-muted); background: var(--theme-background); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 4px; padding: 0.1rem 0.4rem; }
|
||||
.domain-status-badge { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; padding: 0.1rem 0.45rem; border-radius: 8px; letter-spacing: 0.04em; }
|
||||
.domain-status-badge-active { background: #dcfce7; color: #166534; }
|
||||
.domain-status-badge-archived { background: #f1f5f9; color: #64748b; }
|
||||
.domain-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.3rem; }
|
||||
.domain-desc { font-size: 0.82rem; color: var(--theme-foreground-muted); line-height: 1.4; margin-bottom: 0.6rem; }
|
||||
|
||||
/* ── Repo list ───────────────────────────────────────────────────────────── */
|
||||
.domain-repos { border-top: 1px solid var(--theme-foreground-faint, #eee); padding-top: 0.5rem; margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.no-repos { font-size: 0.78rem; color: var(--theme-foreground-faint); font-style: italic; }
|
||||
.repo-row { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.repo-name { font-size: 0.85rem; font-weight: 600; }
|
||||
.repo-path { font-size: 0.72rem; color: var(--theme-foreground-muted); }
|
||||
.repo-url { font-size: 0.72rem; color: var(--theme-foreground-focus); text-decoration: none; }
|
||||
.repo-url:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────────────────────── */
|
||||
.dim { color: gray; font-style: italic; }
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@ const epState = (async function*() {
|
||||
const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]);
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
const wsMap = Object.fromEntries(wsList.map(w => [w.id, {
|
||||
...w, domain: topicMap[w.topic_id]?.domain ?? "unknown",
|
||||
...w, domain: topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
}]));
|
||||
data = epList.map(e => ({
|
||||
...e,
|
||||
@@ -52,7 +52,10 @@ import {MultiSelect} from "./components/multiselect.js";
|
||||
|
||||
const STATUSES = ["open", "in_progress", "addressed", "deferred", "wont_fix"];
|
||||
const PRIORITIES = ["critical", "high", "medium", "low"];
|
||||
const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
|
||||
const DOMAINS = _domainsResp?.ok
|
||||
? (await _domainsResp.json()).map(d => d.slug)
|
||||
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
const EP_TYPES = ["api", "schema", "mcp", "dashboard", "architecture", "integration", "other"];
|
||||
|
||||
const _filtersForm = Inputs.form(
|
||||
|
||||
@@ -22,7 +22,7 @@ const tdState = (async function*() {
|
||||
const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]);
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
const wsMap = Object.fromEntries(wsList.map(w => [w.id, {
|
||||
...w, domain: topicMap[w.topic_id]?.domain ?? "unknown",
|
||||
...w, domain: topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
}]));
|
||||
data = tdList.map(t => ({
|
||||
...t,
|
||||
@@ -52,7 +52,10 @@ import {MultiSelect} from "./components/multiselect.js";
|
||||
|
||||
const STATUSES = ["open", "in_progress", "resolved", "deferred", "wont_fix"];
|
||||
const SEVERITIES = ["critical", "high", "medium", "low"];
|
||||
const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
|
||||
const DOMAINS = _domainsResp?.ok
|
||||
? (await _domainsResp.json()).map(d => d.slug)
|
||||
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
const DEBT_TYPES = ["design", "implementation", "test", "docs", "dependencies", "performance", "security", "other"];
|
||||
|
||||
const _filtersForm = Inputs.form(
|
||||
|
||||
@@ -24,7 +24,7 @@ const wsState = (async function*() {
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
data = wsList.map(w => ({
|
||||
...w,
|
||||
domain: topicMap[w.topic_id]?.domain ?? "unknown",
|
||||
domain: topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
topic_title: topicMap[w.topic_id]?.title ?? "—",
|
||||
}));
|
||||
// open_workstreams from summary carry depends_on / blocks lists
|
||||
@@ -214,8 +214,11 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/workstreams"
|
||||
```js
|
||||
import {MultiSelect} from "./components/multiselect.js";
|
||||
|
||||
// Static options — no dependency on `data`, so selections survive polls
|
||||
const DOMAINS = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
// Load domain slugs from API (dynamic — works with new domains after v0.5)
|
||||
const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
|
||||
const DOMAINS = _domainsResp?.ok
|
||||
? (await _domainsResp.json()).map(d => d.slug)
|
||||
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||
const STATUSES = ["active", "blocked", "completed", "archived"];
|
||||
|
||||
// Create filter form without displaying — shown below the chart
|
||||
|
||||
Reference in New Issue
Block a user