Compare commits
58 Commits
7b8038f79f
...
5c124458f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c124458f3 | |||
| 5399fcec20 | |||
| 8875057984 | |||
| b54ee8149c | |||
| 5fbce5ef6f | |||
| f5c166e77e | |||
| 4fd31d4a6d | |||
| a4563dec14 | |||
| fc5a55a27a | |||
| 4b79c243dc | |||
| 6744ad5f74 | |||
| 0de8c17bc7 | |||
| bf8acd0bed | |||
| 697da3825b | |||
| 81022533b5 | |||
| d403a3ed52 | |||
| d350293e6f | |||
| ba516bb46c | |||
| 86548a4dea | |||
| 93a55ac4e2 | |||
| a32fe1166c | |||
| 20ce332d55 | |||
| 8a2e786111 | |||
| 055c973be0 | |||
| 2f7299ebc8 | |||
| 00d5bcb919 | |||
| f2b847450f | |||
| d9ba1ec7cb | |||
| 61fa59e6e6 | |||
| c54f8eb966 | |||
| 3428a680c3 | |||
| 3e54cb05c8 | |||
| 3d64f288d9 | |||
| ac91e5905c | |||
| 490fdc52d4 | |||
| 1d4c4e31d9 | |||
| 943be6fb97 | |||
| 99a8ba66e9 | |||
| 94aee3d845 | |||
| df84198c2e | |||
| b36e7d469c | |||
| b0edfbb06f | |||
| 99cf8c8386 | |||
| 5594f15590 | |||
| a728d04eae | |||
| c763df3805 | |||
| bb965e4990 | |||
| 18b73f1e2b | |||
| e19953f0b1 | |||
| 68943684e1 | |||
| 48e0dfc0ca | |||
| e5c2ab4114 | |||
| edffc62775 | |||
| c0eec6e168 | |||
| e247c20439 | |||
| 3de29b5a9a | |||
| c0f0e312c0 | |||
| 174546699e |
@@ -1,14 +1,14 @@
|
||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — the-custodian
|
||||
|
||||
**Domain:** railiance
|
||||
**Last synced:** 2026-03-29 15:47 UTC
|
||||
**Domain:** custodian
|
||||
**Last synced:** 2026-03-29 22:48 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### Cross-Repo E2E Sandbox Framework
|
||||
Progress: 8/8 done | workstream_id: `b68de20b-e397-4f97-b1be-ad30711fc2a6`
|
||||
### Interactive — the-custodian
|
||||
Progress: 4/4 done | workstream_id: `370c2481-6806-41eb-a917-f8874f03184f`
|
||||
|
||||
### FOS Hub Bootstrap — Identity, Hub Extraction, Ops Hub, Fin Hub
|
||||
Progress: 0/26 done | workstream_id: `293a74fe-a85a-4ad6-8933-23d52a72fe8b`
|
||||
@@ -64,6 +64,6 @@ Progress: 0/9 done | workstream_id: `9cc32158-2f5c-4ef6-9713-aacce4623d5e`
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("railiance")`
|
||||
`get_domain_summary("custodian")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
||||
@@ -326,6 +326,17 @@ async def get_repo_doi(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-id/{repo_id}", response_model=RepoRead)
|
||||
async def get_repo_by_id(
|
||||
repo_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
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
|
||||
|
||||
|
||||
@router.get("/{slug}/", response_model=RepoRead)
|
||||
async def get_repo(
|
||||
slug: str,
|
||||
|
||||
@@ -2,12 +2,13 @@ import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
@@ -75,27 +76,51 @@ async def update_task(
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Separate token fields from task fields
|
||||
token_fields = {"tokens_in", "tokens_out", "model", "agent", "session_id"}
|
||||
token_field_names = {"tokens_in", "tokens_out", "workplan_tokens_in", "workplan_tokens_out", "token_note", "model", "agent", "session_id"}
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_fields}
|
||||
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names}
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
|
||||
# Create token event if token passthrough fields provided
|
||||
if "tokens_in" in token_data and "tokens_out" in token_data:
|
||||
# Token event — three-tier logic, only when marking done
|
||||
if update_data.get("status") == "done":
|
||||
if "tokens_in" in token_data and "tokens_out" in token_data:
|
||||
# Tier 1: exact counts — default note "measured"; caller may override with token_note
|
||||
tin = token_data["tokens_in"]
|
||||
tout = token_data["tokens_out"]
|
||||
tnote = token_data.get("token_note") or "measured"
|
||||
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
||||
# Tier 2: prorate workplan total across task count
|
||||
count_result = await session.execute(
|
||||
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
|
||||
)
|
||||
task_count = max(count_result.scalar() or 1, 1)
|
||||
tin = token_data["workplan_tokens_in"] // task_count
|
||||
tout = token_data["workplan_tokens_out"] // task_count
|
||||
tnote = "workplan"
|
||||
else:
|
||||
# Tier 3: heuristic fallback
|
||||
tin, tout, tnote = 1000, 500, "heuristic"
|
||||
|
||||
# Resolve repo_id via workstream
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
repo_id = ws.repo_id if ws else None
|
||||
|
||||
event = TokenEvent(
|
||||
task_id=task_id,
|
||||
workstream_id=task.workstream_id,
|
||||
tokens_in=token_data["tokens_in"],
|
||||
tokens_out=token_data["tokens_out"],
|
||||
repo_id=repo_id,
|
||||
tokens_in=tin,
|
||||
tokens_out=tout,
|
||||
model=token_data.get("model"),
|
||||
agent=token_data.get("agent"),
|
||||
session_id=token_data.get("session_id"),
|
||||
ref_type="task",
|
||||
ref_id=str(task_id),
|
||||
note=tnote,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
@@ -6,9 +6,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.schemas.token_event import TokenEventCreate, TokenEventRead, TokenSummary
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.token_event import RepoTokenSummary, TokenEventCreate, TokenEventRead, TokenSummary
|
||||
|
||||
router = APIRouter(prefix="/token-events", tags=["token-events"])
|
||||
|
||||
@@ -26,6 +28,12 @@ async def create_token_event(
|
||||
if task:
|
||||
data["workstream_id"] = task.workstream_id
|
||||
|
||||
# Auto-populate repo_id from workstream if not provided
|
||||
if data.get("workstream_id") and not data.get("repo_id"):
|
||||
ws = await session.get(Workstream, data["workstream_id"])
|
||||
if ws and ws.repo_id:
|
||||
data["repo_id"] = ws.repo_id
|
||||
|
||||
event = TokenEvent(**data)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -90,6 +98,85 @@ async def get_token_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-repo/", response_model=list[RepoTokenSummary])
|
||||
async def get_tokens_by_repo(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RepoTokenSummary]:
|
||||
"""Aggregate token consumption per repo, resolving via the full graph.
|
||||
|
||||
Resolution order for each event:
|
||||
1. token_events.repo_id (direct)
|
||||
2. → workstreams.repo_id (via workstream_id)
|
||||
3. → task.workstream_id → workstreams.repo_id (via task_id)
|
||||
|
||||
Only events that resolve to a repo are included.
|
||||
"""
|
||||
# Fetch all events, workstreams, repos in three queries (avoids N+1)
|
||||
events_result = await session.execute(select(TokenEvent))
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
ws_result = await session.execute(select(Workstream))
|
||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
||||
|
||||
task_result = await session.execute(select(Task))
|
||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||
|
||||
repo_result = await session.execute(select(ManagedRepo))
|
||||
repo_map: dict[uuid.UUID, ManagedRepo] = {r.id: r for r in repo_result.scalars().all()}
|
||||
|
||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||
if e.repo_id:
|
||||
return e.repo_id
|
||||
ws_id = e.workstream_id
|
||||
if not ws_id and e.task_id and e.task_id in task_map:
|
||||
ws_id = task_map[e.task_id].workstream_id
|
||||
if ws_id and ws_id in ws_map:
|
||||
return ws_map[ws_id].repo_id
|
||||
return None
|
||||
|
||||
groups: dict[uuid.UUID, dict] = {}
|
||||
for e in events:
|
||||
rid = resolve_repo_id(e)
|
||||
if not rid or rid not in repo_map:
|
||||
continue
|
||||
if rid not in groups:
|
||||
groups[rid] = {
|
||||
"repo_id": rid,
|
||||
"repo_slug": repo_map[rid].slug,
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"event_count": 0,
|
||||
"by_model": defaultdict(int),
|
||||
"by_note": defaultdict(int),
|
||||
}
|
||||
g = groups[rid]
|
||||
g["tokens_in"] += e.tokens_in
|
||||
g["tokens_out"] += e.tokens_out
|
||||
g["event_count"] += 1
|
||||
if e.model:
|
||||
g["by_model"][e.model] += e.tokens_in + e.tokens_out
|
||||
g["by_note"][e.note or "unknown"] += e.tokens_in + e.tokens_out
|
||||
|
||||
return [
|
||||
RepoTokenSummary(
|
||||
**{k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in g.items()},
|
||||
tokens_total=g["tokens_in"] + g["tokens_out"],
|
||||
)
|
||||
for g in sorted(groups.values(), key=lambda x: -(x["tokens_in"] + x["tokens_out"]))
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=TokenEventRead)
|
||||
async def get_token_event(
|
||||
event_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenEvent:
|
||||
event = await session.get(TokenEvent, event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Token event not found")
|
||||
return event
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TokenEventRead])
|
||||
async def list_token_events(
|
||||
task_id: uuid.UUID | None = None,
|
||||
|
||||
@@ -38,9 +38,16 @@ class TaskUpdate(BaseModel):
|
||||
needs_human: bool | None = None
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
# Optional token passthrough — when provided, a token_event is created
|
||||
# Token passthrough — three tiers (highest precision wins):
|
||||
# 1. tokens_in + tokens_out → exact counts; note defaults to "measured"
|
||||
# 2. workplan_tokens_in + workplan_tokens_out → prorated across task count (note="workplan")
|
||||
# 3. neither provided, status=done → heuristic 1000/500 (note="heuristic")
|
||||
# token_note overrides the auto-assigned note for Tier 1 only (e.g. "userbased")
|
||||
tokens_in: int | None = None
|
||||
tokens_out: int | None = None
|
||||
workplan_tokens_in: int | None = None
|
||||
workplan_tokens_out: int | None = None
|
||||
token_note: str | None = None
|
||||
model: str | None = None
|
||||
agent: str | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
@@ -50,3 +50,14 @@ class TokenSummary(BaseModel):
|
||||
event_count: int
|
||||
by_model: dict[str, int]
|
||||
by_agent: dict[str, int]
|
||||
|
||||
|
||||
class RepoTokenSummary(BaseModel):
|
||||
repo_id: uuid.UUID
|
||||
repo_slug: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
tokens_total: int
|
||||
event_count: int
|
||||
by_model: dict[str, int]
|
||||
by_note: dict[str, int]
|
||||
|
||||
263
state-hub/dashboard/src/components/field-help.js
Normal file
263
state-hub/dashboard/src/components/field-help.js
Normal file
@@ -0,0 +1,263 @@
|
||||
// Field-level help registry for dashboard entity detail pages.
|
||||
//
|
||||
// FIELD_HELP maps a field key to { label, description, doc? }.
|
||||
// label — human-readable field name (used as bold heading in help-tip)
|
||||
// description — one sentence explaining the field
|
||||
// doc — optional anchor into /docs/ pages for "Learn more"
|
||||
//
|
||||
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
|
||||
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
|
||||
//
|
||||
// Usage:
|
||||
// import {fieldRow} from "./components/field-help.js";
|
||||
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
|
||||
|
||||
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
|
||||
import {API} from "./config.js";
|
||||
void HelpTip; // silence unused-import linters
|
||||
|
||||
// ── Entity link registry ────────────────────────────────────────────────────
|
||||
// Maps FK field names to fetch/URL/title resolution rules.
|
||||
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
|
||||
const FIELD_LINKS = {
|
||||
task_id: {
|
||||
apiUrl: id => `${API}/tasks/${id}`,
|
||||
getUrl: (id, _d) => `/tasks/${id}`,
|
||||
getTitle: d => d.title,
|
||||
},
|
||||
workstream_id: {
|
||||
apiUrl: id => `${API}/workstreams/${id}`,
|
||||
getUrl: (id, _d) => `/workstreams/${id}`,
|
||||
getTitle: d => d.title || d.slug,
|
||||
},
|
||||
repo_id: {
|
||||
apiUrl: id => `${API}/repos/by-id/${id}`,
|
||||
getUrl: (_id, d) => `/repos/${d.slug}`,
|
||||
getTitle: d => d.name || d.slug,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an entity-reference value as a link with an async help-tip showing
|
||||
* the entity title. Falls back gracefully if the fetch fails.
|
||||
*/
|
||||
function _linkCell(key, id) {
|
||||
const rule = FIELD_LINKS[key];
|
||||
const shortId = String(id).slice(0, 8) + "…";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.textContent = shortId;
|
||||
a.href = "#";
|
||||
a.style.fontFamily = "var(--monospace, monospace)";
|
||||
a.title = String(id); // full UUID as native tooltip while async loads
|
||||
|
||||
fetch(rule.apiUrl(id))
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const title = rule.getTitle(data);
|
||||
const url = rule.getUrl(id, data);
|
||||
if (url) a.href = url;
|
||||
if (title) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", title);
|
||||
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
|
||||
a.replaceWith(tip);
|
||||
tip.appendChild(a);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* leave plain link */ });
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
export const FIELD_HELP = {
|
||||
// ── TokenEvent ─────────────────────────────────────────────────────────────
|
||||
id: {
|
||||
label: "ID",
|
||||
description: "Unique identifier for this token event (UUID v4).",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_in: {
|
||||
label: "Tokens In",
|
||||
description: "Number of input (prompt) tokens consumed in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_out: {
|
||||
label: "Tokens Out",
|
||||
description: "Number of output (completion) tokens generated in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_total: {
|
||||
label: "Tokens Total",
|
||||
description: "Sum of input and output tokens — total cost proxy for this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
task_id: {
|
||||
label: "Task ID",
|
||||
description: "The task this token event was recorded against (if any).",
|
||||
doc: "/docs/tasks",
|
||||
},
|
||||
workstream_id: {
|
||||
label: "Workstream ID",
|
||||
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
|
||||
doc: "/docs/workstreams",
|
||||
},
|
||||
repo_id: {
|
||||
label: "Repo ID",
|
||||
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
session_id: {
|
||||
label: "Session ID",
|
||||
description: "Opaque identifier for the Claude Code session that produced this event.",
|
||||
},
|
||||
model: {
|
||||
label: "Model",
|
||||
description: "The Claude model used (e.g. claude-sonnet-4-6).",
|
||||
doc: "/docs/reference#models",
|
||||
},
|
||||
agent: {
|
||||
label: "Agent",
|
||||
description: "The agent persona that produced this event (e.g. custodian, ralph).",
|
||||
doc: "/docs/reference#agents",
|
||||
},
|
||||
ref_type: {
|
||||
label: "Ref Type",
|
||||
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
ref_id: {
|
||||
label: "Ref ID",
|
||||
description: "Free-form reference ID; interpretation depends on ref_type.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
note: {
|
||||
label: "Note",
|
||||
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
created_at: {
|
||||
label: "Created At",
|
||||
description: "Timestamp when this token event was recorded (UTC).",
|
||||
},
|
||||
|
||||
// ── Workstream ──────────────────────────────────────────────────────────────
|
||||
slug: {
|
||||
label: "Slug",
|
||||
description: "URL-safe short identifier for this entity.",
|
||||
},
|
||||
title: {
|
||||
label: "Title",
|
||||
description: "Human-readable name for this workstream or task.",
|
||||
},
|
||||
status: {
|
||||
label: "Status",
|
||||
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
|
||||
doc: "/docs/workstream-lifecycle",
|
||||
},
|
||||
topic_id: {
|
||||
label: "Topic ID",
|
||||
description: "The topic this workstream is grouped under.",
|
||||
doc: "/docs/reference#topics",
|
||||
},
|
||||
repo_goal_id: {
|
||||
label: "Repo Goal ID",
|
||||
description: "Optional link to a repo-level strategic goal this workstream advances.",
|
||||
doc: "/docs/goals",
|
||||
},
|
||||
|
||||
// ── Task ───────────────────────────────────────────────────────────────────
|
||||
assignee: {
|
||||
label: "Assignee",
|
||||
description: "Who is responsible for completing this task (agent name or human).",
|
||||
},
|
||||
priority: {
|
||||
label: "Priority",
|
||||
description: "Relative urgency: high, medium, or low.",
|
||||
},
|
||||
due_date: {
|
||||
label: "Due Date",
|
||||
description: "Target completion date (ISO 8601).",
|
||||
},
|
||||
needs_human: {
|
||||
label: "Needs Human",
|
||||
description: "True if the task is blocked waiting for human input or approval.",
|
||||
doc: "/interventions",
|
||||
},
|
||||
intervention_note: {
|
||||
label: "Intervention Note",
|
||||
description: "Why human intervention is required for this task.",
|
||||
},
|
||||
|
||||
// ── Repo ───────────────────────────────────────────────────────────────────
|
||||
repo_slug: {
|
||||
label: "Repo Slug",
|
||||
description: "Short identifier for the repository (matches the git remote slug).",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
event_count: {
|
||||
label: "Event Count",
|
||||
description: "Total number of token events attributed to this entity.",
|
||||
},
|
||||
by_model: {
|
||||
label: "By Model",
|
||||
description: "Token totals broken down by Claude model.",
|
||||
},
|
||||
by_note: {
|
||||
label: "By Note",
|
||||
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single key-value row for an entity detail table.
|
||||
* @param {string} key — field name
|
||||
* @param {*} value — field value (stringified automatically)
|
||||
* @returns {HTMLTableRowElement}
|
||||
*/
|
||||
export function fieldRow(key, value) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
// Key cell
|
||||
const tdKey = document.createElement("td");
|
||||
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
|
||||
|
||||
const help = FIELD_HELP[key];
|
||||
if (help) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", help.label);
|
||||
tip.setAttribute("description", help.description);
|
||||
if (help.doc) tip.setAttribute("doc", help.doc);
|
||||
tip.textContent = help.label;
|
||||
tdKey.appendChild(tip);
|
||||
} else {
|
||||
tdKey.textContent = key;
|
||||
}
|
||||
tr.appendChild(tdKey);
|
||||
|
||||
// Value cell
|
||||
const tdVal = document.createElement("td");
|
||||
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
|
||||
|
||||
let display;
|
||||
if (value === null || value === undefined) {
|
||||
display = document.createElement("span");
|
||||
display.style.color = "var(--theme-foreground-faint,#aaa)";
|
||||
display.textContent = "—";
|
||||
} else if (key in FIELD_LINKS) {
|
||||
display = _linkCell(key, value);
|
||||
} else if (typeof value === "object") {
|
||||
display = document.createElement("pre");
|
||||
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
|
||||
display.textContent = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
display = document.createElement("span");
|
||||
display.textContent = String(value);
|
||||
}
|
||||
tdVal.appendChild(display);
|
||||
tr.appendChild(tdVal);
|
||||
|
||||
return tr;
|
||||
}
|
||||
101
state-hub/dashboard/src/components/ref-cell.js
Normal file
101
state-hub/dashboard/src/components/ref-cell.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// refCell(index, recordType, id) → HTMLElement
|
||||
//
|
||||
// Renders a 1-based row number in a table cell.
|
||||
// Single click — copies deep-link to clipboard and flashes "Copied!".
|
||||
// Double click — opens deep-link in a new tab.
|
||||
//
|
||||
// Deep-link format: <origin>/data/<recordType>/<id>
|
||||
//
|
||||
// Usage:
|
||||
// import {refCell} from "./components/ref-cell.js";
|
||||
// // in an Inputs.table format callback:
|
||||
// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) }
|
||||
|
||||
const _STYLE_ID = "refcell-global-style";
|
||||
if (!document.getElementById(_STYLE_ID)) {
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
.ref-cell {
|
||||
display: inline-block;
|
||||
font-family: var(--monospace, monospace);
|
||||
font-size: 0.78rem;
|
||||
color: var(--theme-foreground-focus, #3b82f6);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ref-cell:hover {
|
||||
background: var(--theme-foreground-faint, #e8f0fe);
|
||||
}
|
||||
.ref-cell-toast {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--theme-background, #fff);
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-foreground, #333);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ref-cell-toast.ref-cell-toast-visible { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _showToast(anchorEl, text) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "ref-cell-toast";
|
||||
toast.textContent = text;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
toast.style.left = `${rect.left}px`;
|
||||
toast.style.top = `${rect.top - toast.offsetHeight - gap}px`;
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible"));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("ref-cell-toast-visible");
|
||||
toast.addEventListener("transitionend", () => toast.remove(), {once: true});
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
export function refCell(index, recordType, id) {
|
||||
const deepLink = `${location.origin}/${recordType}/${id}`;
|
||||
|
||||
const el = document.createElement("span");
|
||||
el.className = "ref-cell";
|
||||
el.title = `Click to copy link · Double-click to open\n${deepLink}`;
|
||||
el.textContent = String(index);
|
||||
|
||||
let clickTimer = null;
|
||||
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// Use a short delay so a double-click cancels the single-click handler.
|
||||
clickTimer = setTimeout(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(deepLink);
|
||||
_showToast(el, "Copied!");
|
||||
} catch {
|
||||
// Fallback for environments where clipboard API is blocked.
|
||||
_showToast(el, deepLink);
|
||||
}
|
||||
}, 180);
|
||||
});
|
||||
|
||||
el.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
clearTimeout(clickTimer);
|
||||
window.open(deepLink, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
29
state-hub/dashboard/src/data/repos/[slug].json.py
Normal file
29
state-hub/dashboard/src/data/repos/[slug].json.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches a single repo by slug."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
slug = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
if not slug:
|
||||
print(json.dumps({"error": "No repo slug provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/repos/{slug}/", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"error": f"Repo {slug!r} not found"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
29
state-hub/dashboard/src/data/token-events/[id].json.py
Normal file
29
state-hub/dashboard/src/data/token-events/[id].json.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches a single token event by ID."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
event_id = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
if not event_id:
|
||||
print(json.dumps({"error": "No event ID provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/token-events/{event_id}", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"error": f"Token event {event_id!r} not found"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
29
state-hub/dashboard/src/data/workstreams/[id].json.py
Normal file
29
state-hub/dashboard/src/data/workstreams/[id].json.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches a single workstream by ID."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
ws_id = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
if not ws_id:
|
||||
print(json.dumps({"error": "No workstream ID provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/workstreams/{ws_id}", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"error": f"Workstream {ws_id!r} not found"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -9,21 +9,26 @@ import {API, POLL} from "./components/config.js";
|
||||
```js
|
||||
const progState = (async function*() {
|
||||
while (true) {
|
||||
let data = [], ok = false;
|
||||
let data = [], tokenEvents = [], ok = false;
|
||||
try {
|
||||
const r = await fetch(`${API}/progress/?limit=500`);
|
||||
ok = r.ok;
|
||||
data = ok ? await r.json() : [];
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch(`${API}/progress/?limit=500`),
|
||||
fetch(`${API}/token-events/?limit=1000`),
|
||||
]);
|
||||
ok = r1.ok;
|
||||
data = ok ? await r1.json() : [];
|
||||
tokenEvents = r2.ok ? await r2.json() : [];
|
||||
} catch {}
|
||||
yield {data, ok, ts: new Date()};
|
||||
yield {data, tokenEvents, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const data = progState.data ?? [];
|
||||
const _ok = progState.ok ?? false;
|
||||
const data = progState.data ?? [];
|
||||
const tokenEvents = progState.tokenEvents ?? [];
|
||||
const _ok = progState.ok ?? false;
|
||||
const _ts = progState.ts;
|
||||
```
|
||||
|
||||
@@ -52,31 +57,55 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/progress-log
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const byDay = Object.entries(
|
||||
data
|
||||
.filter(e => new Date(e.created_at) >= cutoff)
|
||||
.reduce((acc, e) => {
|
||||
const day = e.created_at.slice(0, 10);
|
||||
acc[day] = (acc[day] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {})
|
||||
.reduce((acc, e) => { const d = e.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + 1; return acc; }, {})
|
||||
).sort().map(([day, count]) => ({day, count}));
|
||||
|
||||
const tokensByDay = Object.entries(
|
||||
tokenEvents
|
||||
.filter(e => new Date(e.created_at) >= cutoff)
|
||||
.reduce((acc, e) => { const d = e.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + (e.tokens_in || 0) + (e.tokens_out || 0); return acc; }, {})
|
||||
).sort().map(([day, tokens]) => ({day, tokens}));
|
||||
|
||||
// Scale tokens onto the events axis for dual-axis display
|
||||
const maxEvents = Math.max(1, ...byDay.map(d => d.count));
|
||||
const maxTokens = Math.max(1, ...tokensByDay.map(d => d.tokens));
|
||||
const ratio = maxTokens / maxEvents;
|
||||
const fmtTokens = v => { const t = v * ratio; return t >= 1e6 ? (t/1e6).toFixed(1)+"M" : t >= 1e3 ? Math.round(t/1e3)+"k" : String(Math.round(t)); };
|
||||
|
||||
display(byDay.length === 0
|
||||
? html`<p style="color:gray">No events in the last 30 days.</p>`
|
||||
: Plot.plot({
|
||||
title: "Progress events per day (30-day window)",
|
||||
title: "Progress events & tokens per day (30-day window)",
|
||||
x: {label: "Date", tickRotate: -30},
|
||||
y: {label: "Events", grid: true},
|
||||
y: {label: "← Events", grid: true},
|
||||
marginBottom: 60,
|
||||
marginRight: tokensByDay.length > 0 ? 70 : 20,
|
||||
width: 750,
|
||||
marks: [
|
||||
Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}),
|
||||
Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}),
|
||||
Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.25}),
|
||||
Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue", strokeWidth: 1.5}),
|
||||
Plot.tip(byDay, Plot.pointerX({x: "day", y: "count",
|
||||
title: d => `${d.day}\n${d.count} event${d.count === 1 ? "" : "s"}`})),
|
||||
...(tokensByDay.length > 0 ? [
|
||||
Plot.areaY(tokensByDay, {x: "day", y: d => d.tokens / ratio, fill: "#f28e2b", fillOpacity: 0.2}),
|
||||
Plot.lineY(tokensByDay, {x: "day", y: d => d.tokens / ratio, stroke: "#f28e2b", strokeWidth: 1.5}),
|
||||
Plot.tip(tokensByDay, Plot.pointerX({x: "day", y: d => d.tokens / ratio,
|
||||
title: d => `${d.day}\n${d.tokens.toLocaleString()} tokens`})),
|
||||
Plot.axisY({anchor: "right", label: "Tokens →", tickFormat: fmtTokens}),
|
||||
] : []),
|
||||
Plot.ruleY([0]),
|
||||
],
|
||||
marginBottom: 60,
|
||||
width: 750,
|
||||
})
|
||||
);
|
||||
|
||||
if (byDay.length > 0) display(html`<div style="font-size:0.78rem;display:flex;gap:1.2rem;margin-top:0.4rem;color:var(--theme-foreground-muted)">
|
||||
<span><span style="color:steelblue">▬</span> Events (left axis)</span>
|
||||
${tokensByDay.length > 0 ? html`<span><span style="color:#f28e2b">▬</span> Tokens (right axis)</span>` : html`<span style="font-style:italic">No token data yet</span>`}
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Event Log
|
||||
|
||||
@@ -54,6 +54,28 @@ convention used in the Custodian State Hub.
|
||||
|
||||
---
|
||||
|
||||
## Entity Detail Pages & REF Column
|
||||
|
||||
Every entity table in the dashboard includes a **REF** column (leftmost) that shows
|
||||
the 1-based row number for the current view. Clicking the REF number copies a
|
||||
deep-link to the clipboard in the format `/data/<recordtype>/<id>`. Double-clicking
|
||||
opens that link in a new tab.
|
||||
|
||||
The deep-link target is an entity detail page that renders all fields of the record
|
||||
in a key-value layout. Each field key is decorated with a `<help-tip>` that shows a
|
||||
one-sentence description of the field and a "Learn more" link to the relevant
|
||||
documentation section.
|
||||
|
||||
Currently implemented record types:
|
||||
|
||||
| Record type | URL pattern | Source endpoint |
|
||||
|-------------|-------------|-----------------|
|
||||
| `token-events` | `/token-events/<id>` | `GET /token-events/{id}` |
|
||||
|
||||
Further record types (repos, workstreams, tasks) will be added in subsequent workplans.
|
||||
|
||||
---
|
||||
|
||||
## Meta
|
||||
|
||||
| Topic | What it covers |
|
||||
|
||||
42
state-hub/dashboard/src/repos/[slug].md
Normal file
42
state-hub/dashboard/src/repos/[slug].md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Repo
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const repoSlug = observable.params.slug;
|
||||
const raw = await fetch(`${API}/repos/${repoSlug}/`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const name = raw.name || raw.slug || repoSlug;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Repo · <em>${name}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/repos">← Repos</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","slug","name","domain_slug","status","description",
|
||||
"local_path","remote_url","git_fingerprint",
|
||||
"sbom_source","last_sbom_at","last_state_synced_at",
|
||||
"created_at","updated_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
42
state-hub/dashboard/src/tasks/[id].md
Normal file
42
state-hub/dashboard/src/tasks/[id].md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Task
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const taskId = observable.params.id;
|
||||
const raw = await fetch(`${API}/tasks/${taskId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const name = raw.title || taskId;
|
||||
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Task · <em>${shortName}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/tasks">← Tasks</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","title","status","priority","assignee",
|
||||
"workstream_id","due_date","needs_human","intervention_note",
|
||||
"created_at","updated_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
@@ -4,68 +4,107 @@ title: Token Cost
|
||||
|
||||
```js
|
||||
import {API} from "./components/config.js";
|
||||
import {refCell} from "./components/ref-cell.js";
|
||||
const POLL = 60_000;
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll for token data
|
||||
// Fetch token events, by-repo summary, workstreams, and tasks in parallel
|
||||
const tokenState = (async function*() {
|
||||
while (true) {
|
||||
let data = {by_repo: [], by_workstream: [], top_tasks: [], by_model: [], total_events: 0}, ok = false;
|
||||
let byRepo = [], events = [], wsMap = {}, taskMap = {}, ok = false;
|
||||
try {
|
||||
const r = await fetch(`${API}/token-events/?limit=1000`);
|
||||
ok = r.ok;
|
||||
const [r1, r2, r3, r4] = await Promise.all([
|
||||
fetch(`${API}/token-events/by-repo/`),
|
||||
fetch(`${API}/token-events/?limit=1000`),
|
||||
fetch(`${API}/workstreams/`),
|
||||
fetch(`${API}/tasks/`),
|
||||
]);
|
||||
ok = r1.ok && r2.ok;
|
||||
if (ok) {
|
||||
const events = await r.json();
|
||||
data = buildSummary(events);
|
||||
byRepo = await r1.json();
|
||||
events = await r2.json();
|
||||
}
|
||||
if (r3.ok) {
|
||||
const wsList = await r3.json();
|
||||
for (const w of wsList) wsMap[w.id] = w;
|
||||
}
|
||||
if (r4.ok) {
|
||||
const taskList = await r4.json();
|
||||
for (const t of taskList) taskMap[t.id] = t;
|
||||
}
|
||||
} catch {}
|
||||
yield {data, ok, ts: new Date()};
|
||||
yield {byRepo, events, wsMap, taskMap, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
// Resolve an event's repo_id via the 3-level chain: direct → workstream → task→workstream
|
||||
function resolveRepoId(e, wsMap, taskMap) {
|
||||
if (e.repo_id) return e.repo_id;
|
||||
const wsId = e.workstream_id ?? taskMap[e.task_id]?.workstream_id;
|
||||
return wsId ? (wsMap[wsId]?.repo_id ?? null) : null;
|
||||
}
|
||||
|
||||
function buildSummary(events) {
|
||||
const byRepo = {}, byWs = {}, byModel = {}, byTask = {};
|
||||
const byWs = {}, byModel = {}, byTask = {};
|
||||
for (const e of events) {
|
||||
const tot = (e.tokens_in || 0) + (e.tokens_out || 0);
|
||||
if (e.repo_id) {
|
||||
byRepo[e.repo_id] = byRepo[e.repo_id] || {scope_id: e.repo_id, tokens_in: 0, tokens_out: 0, event_count: 0};
|
||||
byRepo[e.repo_id].tokens_in += e.tokens_in || 0;
|
||||
byRepo[e.repo_id].tokens_out += e.tokens_out || 0;
|
||||
byRepo[e.repo_id].event_count++;
|
||||
}
|
||||
if (e.workstream_id) {
|
||||
byWs[e.workstream_id] = byWs[e.workstream_id] || {scope_id: e.workstream_id, tokens_in: 0, tokens_out: 0, event_count: 0};
|
||||
byWs[e.workstream_id].tokens_in += e.tokens_in || 0;
|
||||
byWs[e.workstream_id].tokens_in += e.tokens_in || 0;
|
||||
byWs[e.workstream_id].tokens_out += e.tokens_out || 0;
|
||||
byWs[e.workstream_id].event_count++;
|
||||
}
|
||||
const model = e.model || "unknown";
|
||||
byModel[model] = (byModel[model] || 0) + tot;
|
||||
if (e.task_id) {
|
||||
byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0};
|
||||
byTask[e.task_id].tokens_in += e.tokens_in || 0;
|
||||
byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0, event_count: 0};
|
||||
byTask[e.task_id].tokens_in += e.tokens_in || 0;
|
||||
byTask[e.task_id].tokens_out += e.tokens_out || 0;
|
||||
byTask[e.task_id].event_count++;
|
||||
}
|
||||
}
|
||||
const sortDesc = obj => Object.entries(obj)
|
||||
.map(([k,v]) => typeof v === "number" ? {id: k, tokens_total: v} : {...v, tokens_total: (v.tokens_in||0)+(v.tokens_out||0)})
|
||||
.sort((a,b) => b.tokens_total - a.tokens_total);
|
||||
const toRows = obj => Object.values(obj)
|
||||
.map(v => ({...v, tokens_total: (v.tokens_in || 0) + (v.tokens_out || 0)}))
|
||||
.sort((a, b) => b.tokens_total - a.tokens_total);
|
||||
return {
|
||||
by_repo: sortDesc(byRepo),
|
||||
by_workstream: sortDesc(byWs),
|
||||
by_model: Object.entries(byModel).map(([model,tokens_total]) => ({model,tokens_total})).sort((a,b)=>b.tokens_total-a.tokens_total),
|
||||
top_tasks: sortDesc(byTask).slice(0,10),
|
||||
by_workstream: toRows(byWs),
|
||||
by_model: Object.entries(byModel)
|
||||
.map(([model, tokens_total]) => ({model, tokens_total}))
|
||||
.sort((a, b) => b.tokens_total - a.tokens_total),
|
||||
top_tasks: toRows(byTask),
|
||||
total_events: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
function nameCell(name, fullName) {
|
||||
const s = String(name ?? fullName ?? "—");
|
||||
const full = String(fullName ?? name ?? "—");
|
||||
const el = document.createElement("span");
|
||||
el.title = full;
|
||||
el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s;
|
||||
return el;
|
||||
}
|
||||
|
||||
function sortRows(rows, sortField) {
|
||||
if (sortField === "Tokens Total") return rows; // already sorted by buildSummary / by-repo API
|
||||
const s = [...rows];
|
||||
if (sortField === "Tokens In") s.sort((a, b) => (b.tokens_in || 0) - (a.tokens_in || 0));
|
||||
else if (sortField === "Tokens Out") s.sort((a, b) => (b.tokens_out || 0) - (a.tokens_out || 0));
|
||||
else if (sortField === "Event Count") s.sort((a, b) => (b.event_count || 0) - (a.event_count || 0));
|
||||
else if (sortField === "Most Recent") s.sort((a, b) => (b._lastAt || 0) - (a._lastAt || 0));
|
||||
return s;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const td = tokenState.data ?? {by_repo:[], by_workstream:[], top_tasks:[], by_model:[], total_events:0};
|
||||
const byRepo = tokenState.byRepo ?? [];
|
||||
const events = tokenState.events ?? [];
|
||||
const wsMap = tokenState.wsMap ?? {};
|
||||
const taskMap = tokenState.taskMap ?? {};
|
||||
const _ok = tokenState.ok ?? false;
|
||||
const _ts = tokenState.ts;
|
||||
```
|
||||
@@ -73,69 +112,158 @@ const _ts = tokenState.ts;
|
||||
# Token Cost
|
||||
|
||||
```js
|
||||
const _liveEl = html`<div style="font-size:0.8rem;color:${_ok?'var(--theme-foreground-focus)':'red'}">
|
||||
● ${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${td.total_events} events` : "API offline"}
|
||||
</div>`;
|
||||
display(_liveEl);
|
||||
display(html`<div style="font-size:0.8rem;color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">
|
||||
● ${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${events.length} events` : "API offline"}
|
||||
</div>`);
|
||||
```
|
||||
|
||||
```js
|
||||
const repoSel = Inputs.select(
|
||||
["All repos", ...byRepo.map(r => r.repo_slug)],
|
||||
{label: "Filter by repo"}
|
||||
);
|
||||
const sortSel = Inputs.select(
|
||||
["Tokens Total", "Tokens In", "Tokens Out", "Event Count", "Most Recent"],
|
||||
{label: "Sort by"}
|
||||
);
|
||||
const maxSel = Inputs.select(
|
||||
[10, 20, 50, 100, 500],
|
||||
{value: 20, label: "Show"}
|
||||
);
|
||||
display(html`<div style="display:flex;gap:1.5rem;align-items:flex-end;flex-wrap:wrap;margin:0.5rem 0 1.5rem">${repoSel}${sortSel}${maxSel}</div>`);
|
||||
const repoFilter = view(repoSel);
|
||||
const sortOrder = view(sortSel);
|
||||
const maxResults = view(maxSel);
|
||||
```
|
||||
|
||||
```js
|
||||
// Build filtered and last-event-annotated row sets
|
||||
const selectedRepoId = repoFilter === "All repos"
|
||||
? null
|
||||
: (byRepo.find(r => r.repo_slug === repoFilter)?.repo_id ?? null);
|
||||
|
||||
const filteredEvents = selectedRepoId
|
||||
? events.filter(e => resolveRepoId(e, wsMap, taskMap) === selectedRepoId)
|
||||
: events;
|
||||
|
||||
const lastAtByRepo = {}, lastAtByWs = {}, lastAtByTask = {};
|
||||
for (const e of filteredEvents) {
|
||||
const t = e.created_at ? new Date(e.created_at).getTime() : 0;
|
||||
const rid = resolveRepoId(e, wsMap, taskMap);
|
||||
if (rid) lastAtByRepo[rid] = Math.max(lastAtByRepo[rid] || 0, t);
|
||||
if (e.workstream_id) lastAtByWs[e.workstream_id] = Math.max(lastAtByWs[e.workstream_id] || 0, t);
|
||||
if (e.task_id) lastAtByTask[e.task_id] = Math.max(lastAtByTask[e.task_id] || 0, t);
|
||||
}
|
||||
|
||||
const filteredByRepo = (selectedRepoId
|
||||
? byRepo.filter(r => r.repo_id === selectedRepoId)
|
||||
: byRepo
|
||||
).map(r => ({...r, _lastAt: lastAtByRepo[r.repo_id] || 0}));
|
||||
|
||||
const summary = buildSummary(filteredEvents);
|
||||
const wsRowsFull = summary.by_workstream.map(r => ({...r, _lastAt: lastAtByWs[r.scope_id] || 0}));
|
||||
const taskRowsFull = summary.top_tasks.map(r => ({...r, _lastAt: lastAtByTask[r.task_id] || 0}));
|
||||
```
|
||||
|
||||
## By Repo
|
||||
|
||||
```js
|
||||
if (td.by_repo.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No token events recorded yet.</p>`);
|
||||
} else {
|
||||
display(Plot.plot({
|
||||
title: "Token consumption by repo",
|
||||
marginLeft: 160,
|
||||
width: Math.min(900, width),
|
||||
x: {label: "Tokens", tickFormat: "~s"},
|
||||
y: {label: null},
|
||||
color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]},
|
||||
marks: [
|
||||
Plot.barX(
|
||||
td.by_repo.flatMap(r => [
|
||||
{repo: r.scope_id.slice(0,8), type: "tokens_in", value: r.tokens_in},
|
||||
{repo: r.scope_id.slice(0,8), type: "tokens_out", value: r.tokens_out},
|
||||
]),
|
||||
{x: "value", y: "repo", fill: "type", tip: true}
|
||||
),
|
||||
],
|
||||
}));
|
||||
{
|
||||
const sorted = sortRows(filteredByRepo, sortOrder);
|
||||
const total = sorted.length;
|
||||
const rows = sorted.slice(0, maxResults);
|
||||
|
||||
if (rows.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No token events with repo association yet.</p>`);
|
||||
} else {
|
||||
display(Plot.plot({
|
||||
title: "Token consumption by repo",
|
||||
marginLeft: 160,
|
||||
width: Math.min(900, width),
|
||||
x: {label: "Tokens", tickFormat: "~s"},
|
||||
y: {label: null},
|
||||
color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]},
|
||||
marks: [
|
||||
Plot.barX(
|
||||
rows.flatMap(r => [
|
||||
{repo: r.repo_slug, type: "tokens_in", value: r.tokens_in},
|
||||
{repo: r.repo_slug, type: "tokens_out", value: r.tokens_out},
|
||||
]),
|
||||
{x: "value", y: "repo", fill: "type", tip: true}
|
||||
),
|
||||
],
|
||||
}));
|
||||
|
||||
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
|
||||
columns: ["_ref", "repo_slug", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||
header: {
|
||||
_ref: "REF",
|
||||
repo_slug: "Repo",
|
||||
tokens_in: "Tokens In",
|
||||
tokens_out: "Tokens Out",
|
||||
tokens_total: "Total",
|
||||
event_count: "Events",
|
||||
},
|
||||
format: {
|
||||
_ref: (_, i) => refCell(i + 1, "repos", rows[i].repo_slug),
|
||||
repo_slug: d => nameCell(d, d),
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {_ref: 50, repo_slug: 160, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
||||
}));
|
||||
|
||||
if (total > maxResults)
|
||||
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} repos</p>`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## By Workplan
|
||||
|
||||
```js
|
||||
const wsRows = td.by_workstream.slice(0, 20);
|
||||
if (wsRows.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(wsRows, {
|
||||
columns: ["scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||
header: {
|
||||
scope_id: "Workstream ID",
|
||||
tokens_in: "Tokens In",
|
||||
tokens_out: "Tokens Out",
|
||||
tokens_total: "Total",
|
||||
event_count: "Events",
|
||||
},
|
||||
format: {
|
||||
scope_id: d => d.slice(0,8) + "…",
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {scope_id: 120, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
||||
}));
|
||||
{
|
||||
const sorted = sortRows(wsRowsFull, sortOrder);
|
||||
const total = sorted.length;
|
||||
const rows = sorted.slice(0, maxResults);
|
||||
|
||||
if (rows.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
|
||||
columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||
header: {
|
||||
_ref: "REF",
|
||||
scope_id: "Workstream",
|
||||
tokens_in: "Tokens In",
|
||||
tokens_out: "Tokens Out",
|
||||
tokens_total: "Total",
|
||||
event_count: "Events",
|
||||
},
|
||||
format: {
|
||||
_ref: (_, i) => refCell(i + 1, "workstreams", rows[i].scope_id),
|
||||
scope_id: d => {
|
||||
const ws = wsMap[d];
|
||||
return nameCell(ws?.title ?? ws?.slug, d);
|
||||
},
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
||||
}));
|
||||
|
||||
if (total > maxResults)
|
||||
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} workstreams</p>`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## By Model
|
||||
|
||||
```js
|
||||
if (td.by_model.length === 0) {
|
||||
if (summary.by_model.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No model data yet.</p>`);
|
||||
} else {
|
||||
display(Plot.plot({
|
||||
@@ -144,27 +272,41 @@ if (td.by_model.length === 0) {
|
||||
width: Math.min(700, width),
|
||||
x: {label: "Total tokens", tickFormat: "~s"},
|
||||
marks: [
|
||||
Plot.barX(td.by_model, {x: "tokens_total", y: "model", fill: "#4e79a7", tip: true}),
|
||||
Plot.barX(summary.by_model, {x: "tokens_total", y: "model", fill: "#4e79a7", tip: true}),
|
||||
],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Top 10 Tasks by Tokens
|
||||
## Top Tasks by Tokens
|
||||
|
||||
```js
|
||||
if (td.top_tasks.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(td.top_tasks, {
|
||||
columns: ["task_id", "tokens_in", "tokens_out", "tokens_total"],
|
||||
header: {task_id: "Task ID", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
|
||||
format: {
|
||||
task_id: d => d.slice(0,8) + "…",
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
}));
|
||||
{
|
||||
const sorted = sortRows(taskRowsFull, sortOrder);
|
||||
const total = sorted.length;
|
||||
const rows = sorted.slice(0, maxResults);
|
||||
|
||||
if (rows.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
|
||||
columns: ["_ref", "task_id", "tokens_in", "tokens_out", "tokens_total"],
|
||||
header: {_ref: "REF", task_id: "Task", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
|
||||
format: {
|
||||
_ref: (_, i) => refCell(i + 1, "tasks", rows[i].task_id),
|
||||
task_id: d => {
|
||||
const task = taskMap[d];
|
||||
return nameCell(task?.title, d);
|
||||
},
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {_ref: 50, task_id: 240},
|
||||
}));
|
||||
|
||||
if (total > maxResults)
|
||||
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} tasks</p>`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
42
state-hub/dashboard/src/token-events/[id].md
Normal file
42
state-hub/dashboard/src/token-events/[id].md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Token Event
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const eventId = observable.params.id;
|
||||
const raw = await fetch(`${API}/token-events/${eventId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const shortId = raw.id ? raw.id.slice(0, 8) + "…" : eventId;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Token Event · <code>${shortId}</code></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","tokens_in","tokens_out","tokens_total",
|
||||
"note","model","agent","session_id",
|
||||
"task_id","workstream_id","repo_id",
|
||||
"ref_type","ref_id","created_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
41
state-hub/dashboard/src/workstreams/[id].md
Normal file
41
state-hub/dashboard/src/workstreams/[id].md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Workstream
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const wsId = observable.params.id;
|
||||
const raw = await fetch(`${API}/workstreams/${wsId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const name = raw.title || raw.slug || wsId;
|
||||
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/workstreams">← Workstreams</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","slug","title","status","topic_id","repo_id","repo_goal_id",
|
||||
"created_at","updated_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
@@ -83,6 +83,16 @@ Agents should call `record_token_event` (or pass `tokens_in`/`tokens_out` via
|
||||
|------|----------|-------|
|
||||
| `record_token_event(tokens_in, tokens_out, ...)` | `task_id`?, `workstream_id`?, `repo_id`?, `model`?, `agent`?, `ref_type`?, `ref_id`?, `note`?, `session_id`? | POSTs to `/token-events/`. `workstream_id` auto-filled from task. Returns event id + running total. |
|
||||
| `get_token_summary(scope, id)` | `scope`: task\|workstream\|repo\|commit\|release\|session; `id`: UUID or ref string | Returns formatted table of tokens_in/out/total, event_count, by_model, by_agent. |
|
||||
| `record_interactive_task(title, repo_slug, ...)` | `tokens_in`?, `tokens_out`?, `note`?, `model`?, `agent`?, `description`?, `session_id`? | Find-or-create `interactive-<repo>` workstream, create task, mark done, record token event. |
|
||||
|
||||
**Token note taxonomy:**
|
||||
|
||||
| note | meaning |
|
||||
|------|---------|
|
||||
| `"measured"` | Exact counts read from Claude Code status bar — default when `tokens_in`/`tokens_out` provided |
|
||||
| `"userbased"` | Counts provided by a human (pass `note="userbased"` explicitly) |
|
||||
| `"workplan"` | Prorated from workplan total across task count |
|
||||
| `"heuristic"` | Server fallback — 1 000 in / 500 out, no agent input |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -428,29 +428,58 @@ def update_task_status(
|
||||
blocking_reason: Optional[str] = None,
|
||||
tokens_in: Optional[int] = None,
|
||||
tokens_out: Optional[int] = None,
|
||||
workplan_tokens_in: Optional[int] = None,
|
||||
workplan_tokens_out: Optional[int] = None,
|
||||
note: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
agent: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Update a task's status. blocking_reason is required when status='blocked'.
|
||||
|
||||
Optionally record token consumption in one call by passing tokens_in/tokens_out.
|
||||
When provided, a token_event is created automatically with workstream_id and
|
||||
repo_id auto-populated from the task.
|
||||
When status='done', always records a token event using the best available data:
|
||||
Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session
|
||||
note defaults to "measured"; pass note="userbased" if the
|
||||
numbers were provided by a human rather than read from the bar
|
||||
Tier 2: pass workplan_tokens_in + workplan_tokens_out — total workplan
|
||||
effort prorated across task count (note="workplan")
|
||||
Tier 3 (fallback): no token args — heuristic 1000 in / 500 out (note="heuristic")
|
||||
|
||||
Best practice: read tokens from the Claude Code status bar and pass exact counts.
|
||||
|
||||
Args:
|
||||
task_id: UUID of the task
|
||||
status: todo | in_progress | blocked | done | cancelled
|
||||
blocking_reason: required when status=blocked
|
||||
tokens_in: optional input token count (triggers token_event creation)
|
||||
tokens_out: optional output token count (required if tokens_in provided)
|
||||
model: optional model identifier, e.g. 'claude-sonnet-4-6'
|
||||
agent: optional agent name, e.g. 'custodian', 'ralph'
|
||||
session_id: optional agent session identifier
|
||||
tokens_in: exact input token count for this task (Tier 1)
|
||||
tokens_out: exact output token count for this task (Tier 1)
|
||||
workplan_tokens_in: total input tokens for the whole workplan (Tier 2)
|
||||
workplan_tokens_out: total output tokens for the whole workplan (Tier 2)
|
||||
note: override the auto note — use "userbased" when counts came from a human;
|
||||
omit to get the default ("measured" for Tier 1, "workplan"/"heuristic" otherwise)
|
||||
model: model identifier, e.g. 'claude-sonnet-4-6'
|
||||
agent: agent name, e.g. 'custodian', 'ralph'
|
||||
session_id: agent session identifier
|
||||
"""
|
||||
body: dict[str, Any] = {"status": status}
|
||||
body: dict[str, Any] = {
|
||||
"status": status,
|
||||
"model": model,
|
||||
"agent": agent,
|
||||
"session_id": session_id,
|
||||
}
|
||||
if blocking_reason:
|
||||
body["blocking_reason"] = blocking_reason
|
||||
if tokens_in is not None:
|
||||
body["tokens_in"] = tokens_in
|
||||
if tokens_out is not None:
|
||||
body["tokens_out"] = tokens_out
|
||||
if workplan_tokens_in is not None:
|
||||
body["workplan_tokens_in"] = workplan_tokens_in
|
||||
if workplan_tokens_out is not None:
|
||||
body["workplan_tokens_out"] = workplan_tokens_out
|
||||
if note is not None:
|
||||
body["token_note"] = note
|
||||
|
||||
task = _patch(f"/tasks/{task_id}", body)
|
||||
_post("/progress", {
|
||||
"task_id": task_id,
|
||||
@@ -461,19 +490,6 @@ def update_task_status(
|
||||
"detail": {"blocking_reason": blocking_reason},
|
||||
})
|
||||
|
||||
if tokens_in is not None and tokens_out is not None:
|
||||
_post("/token-events", {
|
||||
"task_id": task_id,
|
||||
"workstream_id": task.get("workstream_id"),
|
||||
"tokens_in": tokens_in,
|
||||
"tokens_out": tokens_out,
|
||||
"model": model,
|
||||
"agent": agent,
|
||||
"session_id": session_id,
|
||||
"ref_type": "task",
|
||||
"ref_id": task_id,
|
||||
})
|
||||
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
|
||||
@@ -2213,6 +2229,122 @@ def get_doi_summary() -> str:
|
||||
return json.dumps(_get("/repos/doi/summary"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive / ad-hoc task recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def record_interactive_task(
|
||||
title: str,
|
||||
repo_slug: str,
|
||||
tokens_in: Optional[int] = None,
|
||||
tokens_out: Optional[int] = None,
|
||||
note: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
agent: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Record ad-hoc interactive work as a task with token consumption.
|
||||
|
||||
Finds or creates a persistent 'interactive-<repo>' workstream for the repo,
|
||||
creates the task, marks it done immediately, and records a token event.
|
||||
|
||||
Token note convention:
|
||||
"measured" — exact counts read from the Claude Code status bar (default when
|
||||
tokens_in/tokens_out provided and note omitted)
|
||||
"userbased" — counts provided by a human (pass note="userbased" explicitly)
|
||||
"heuristic" — server fallback when no counts given (automatic)
|
||||
|
||||
Use this for work done outside a formal workplan: quick fixes, config changes,
|
||||
code reviews, one-off investigations, or any session work worth tracking.
|
||||
|
||||
Args:
|
||||
title: Short description of the work done
|
||||
repo_slug: Registered repo slug, e.g. 'the-custodian', 'inter-hub'
|
||||
tokens_in: Input token count (Tier 1 — read from Claude Code status bar)
|
||||
tokens_out: Output token count (Tier 1)
|
||||
note: Override token note — use "userbased" when counts came from a human
|
||||
model: Model identifier, e.g. 'claude-sonnet-4-6'
|
||||
agent: Agent name, e.g. 'custodian', 'ralph'
|
||||
description: Optional longer description of what was done
|
||||
session_id: Agent session identifier
|
||||
"""
|
||||
# Resolve repo
|
||||
repos = _get("/repos/")
|
||||
if isinstance(repos, dict) and "error" in repos:
|
||||
return json.dumps(repos)
|
||||
repo = next((r for r in (repos or []) if r.get("slug") == repo_slug), None)
|
||||
if not repo:
|
||||
return json.dumps({"error": f"Repo not found: {repo_slug!r}. Register it first with register_repo()."})
|
||||
|
||||
repo_id = repo["id"]
|
||||
domain_slug = repo.get("domain_slug") or repo.get("domain")
|
||||
ws_slug = f"interactive-{repo_slug}"
|
||||
|
||||
# Find or create the interactive workstream
|
||||
existing = _get("/workstreams/", {"slug": ws_slug})
|
||||
ws = existing[0] if isinstance(existing, list) and existing else None
|
||||
|
||||
if not ws:
|
||||
# Find a topic for this domain to satisfy the FK
|
||||
topics = _get("/topics/")
|
||||
topic = next(
|
||||
(t for t in (topics if isinstance(topics, list) else [])
|
||||
if t.get("domain_slug") == domain_slug or t.get("domain") == domain_slug),
|
||||
None,
|
||||
)
|
||||
if not topic:
|
||||
return json.dumps({"error": f"No topic found for domain {domain_slug!r} — cannot create workstream."})
|
||||
|
||||
ws = _post("/workstreams", {
|
||||
"topic_id": topic["id"],
|
||||
"slug": ws_slug,
|
||||
"title": f"Interactive — {repo_slug}",
|
||||
"description": "Ad-hoc tasks created outside a formal workplan.",
|
||||
"owner": "custodian",
|
||||
"repo_id": repo_id,
|
||||
})
|
||||
if "error" in ws:
|
||||
return json.dumps(ws)
|
||||
|
||||
# Create task
|
||||
task = _post("/tasks", {
|
||||
"workstream_id": ws["id"],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"priority": "medium",
|
||||
})
|
||||
if "error" in task:
|
||||
return json.dumps(task)
|
||||
|
||||
# Mark done — triggers three-tier token recording in the router
|
||||
body: dict[str, Any] = {
|
||||
"status": "done",
|
||||
"model": model,
|
||||
"agent": agent,
|
||||
"session_id": session_id,
|
||||
}
|
||||
if tokens_in is not None:
|
||||
body["tokens_in"] = tokens_in
|
||||
if tokens_out is not None:
|
||||
body["tokens_out"] = tokens_out
|
||||
if note is not None:
|
||||
body["token_note"] = note
|
||||
|
||||
_patch(f"/tasks/{task['id']}", body)
|
||||
|
||||
effective_note = note or ("measured" if tokens_in is not None else "heuristic")
|
||||
return json.dumps({
|
||||
"task_id": task["id"],
|
||||
"workstream_id": ws["id"],
|
||||
"workstream_slug": ws_slug,
|
||||
"title": title,
|
||||
"token_note": effective_note,
|
||||
}, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -196,3 +196,22 @@ class TestTokenSummary:
|
||||
s = r.json()
|
||||
assert s["tokens_total"] == 0
|
||||
assert s["event_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTokenEventGetById:
|
||||
async def test_get_by_id(self, client):
|
||||
ev = await _post_event(client, tokens_in=111, tokens_out=222, note="get-by-id-test")
|
||||
r = await client.get(f"/token-events/{ev['id']}")
|
||||
assert r.status_code == 200
|
||||
result = r.json()
|
||||
assert result["id"] == ev["id"]
|
||||
assert result["tokens_in"] == 111
|
||||
assert result["tokens_out"] == 222
|
||||
assert result["tokens_total"] == 333
|
||||
assert result["note"] == "get-by-id-test"
|
||||
|
||||
async def test_get_by_id_not_found(self, client):
|
||||
import uuid
|
||||
r = await client.get(f"/token-events/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
Token passthrough test: update_task_status with tokens_in/tokens_out
|
||||
creates a token event automatically.
|
||||
Token passthrough test: update_task_status creates a token event on done.
|
||||
|
||||
Tests the API-level behaviour (the MCP tool delegates to the same endpoints).
|
||||
Three-tier logic:
|
||||
Tier 1 — exact tokens_in/tokens_out provided
|
||||
Tier 2 — workplan_tokens_in/out provided → prorated by task count (note="workplan")
|
||||
Tier 3 — no token args, status=done → heuristic 1000/500 (note="heuristic")
|
||||
|
||||
Non-done status changes never create a token event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,22 +31,21 @@ async def _create_workstream(client, topic_id):
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_task(client, workstream_id):
|
||||
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "my task"})
|
||||
async def _create_task(client, workstream_id, title="my task"):
|
||||
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTokenPassthrough:
|
||||
async def test_update_status_with_tokens_creates_event(self, client):
|
||||
"""PATCH /tasks/{id} with tokens_in/tokens_out creates a token_event."""
|
||||
async def test_tier1_exact_tokens(self, client):
|
||||
"""Tier 1: exact tokens_in/tokens_out → used as-is, no note."""
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
task = await _create_task(client, ws["id"])
|
||||
|
||||
# Update task status with token data
|
||||
r = await client.patch(f"/tasks/{task['id']}", json={
|
||||
"status": "done",
|
||||
"tokens_in": 1200,
|
||||
@@ -53,10 +56,7 @@ class TestTokenPassthrough:
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "done"
|
||||
|
||||
# Token event should now exist for this task
|
||||
r2 = await client.get("/token-events/", params={"task_id": task["id"]})
|
||||
assert r2.status_code == 200
|
||||
events = r2.json()
|
||||
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["tokens_in"] == 1200
|
||||
@@ -65,9 +65,69 @@ class TestTokenPassthrough:
|
||||
assert ev["model"] == "claude-sonnet-4-6"
|
||||
assert ev["agent"] == "custodian"
|
||||
assert ev["workstream_id"] == ws["id"]
|
||||
assert ev["note"] == "measured"
|
||||
|
||||
async def test_update_status_without_tokens_creates_no_event(self, client):
|
||||
"""PATCH /tasks/{id} without token fields creates no token_event."""
|
||||
async def test_tier1_userbased_note_override(self, client):
|
||||
"""Tier 1 with note='userbased' records that note instead of 'measured'."""
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
task = await _create_task(client, ws["id"])
|
||||
|
||||
r = await client.patch(f"/tasks/{task['id']}", json={
|
||||
"status": "done",
|
||||
"tokens_in": 500,
|
||||
"tokens_out": 200,
|
||||
"token_note": "userbased",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
||||
assert events[0]["note"] == "userbased"
|
||||
|
||||
async def test_tier2_workplan_prorated(self, client):
|
||||
"""Tier 2: workplan totals prorated across 4 tasks → 250/125 each, note='workplan'."""
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
# Create 4 tasks; mark the first done with workplan totals
|
||||
task = await _create_task(client, ws["id"], "T1")
|
||||
for title in ["T2", "T3", "T4"]:
|
||||
await _create_task(client, ws["id"], title)
|
||||
|
||||
r = await client.patch(f"/tasks/{task['id']}", json={
|
||||
"status": "done",
|
||||
"workplan_tokens_in": 1000,
|
||||
"workplan_tokens_out": 500,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
|
||||
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["tokens_in"] == 250 # 1000 // 4
|
||||
assert ev["tokens_out"] == 125 # 500 // 4
|
||||
assert ev["note"] == "workplan"
|
||||
|
||||
async def test_tier3_heuristic_fallback(self, client):
|
||||
"""Tier 3: status=done with no token args → heuristic 1000/500, note='heuristic'."""
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
task = await _create_task(client, ws["id"])
|
||||
|
||||
r = await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
|
||||
assert r.status_code == 200
|
||||
|
||||
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
||||
assert len(events) == 1
|
||||
ev = events[0]
|
||||
assert ev["tokens_in"] == 1000
|
||||
assert ev["tokens_out"] == 500
|
||||
assert ev["note"] == "heuristic"
|
||||
|
||||
async def test_non_done_status_creates_no_event(self, client):
|
||||
"""Non-done status updates never create a token event."""
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
@@ -76,6 +136,5 @@ class TestTokenPassthrough:
|
||||
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r2 = await client.get("/token-events/", params={"task_id": task["id"]})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json() == []
|
||||
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
|
||||
assert events == []
|
||||
|
||||
332
workplans/CUST-WP-0030-dashboard-entity-list-ux.md
Normal file
332
workplans/CUST-WP-0030-dashboard-entity-list-ux.md
Normal file
@@ -0,0 +1,332 @@
|
||||
---
|
||||
id: CUST-WP-0030
|
||||
type: workplan
|
||||
title: "Dashboard Entity List UX"
|
||||
domain: custodian
|
||||
repo: the-custodian
|
||||
status: done
|
||||
owner: custodian
|
||||
topic_slug: custodian
|
||||
created: "2026-03-29"
|
||||
updated: "2026-03-29"
|
||||
state_hub_workstream_id: "9d8e1c33-2067-4593-a5d8-d28dda3b1d21"
|
||||
---
|
||||
|
||||
# Dashboard Entity List UX
|
||||
|
||||
## Goal
|
||||
|
||||
Make every entity table in the dashboard navigable and self-documenting.
|
||||
Two new UI primitives:
|
||||
|
||||
1. **REF cell** — running row number (1-based); click copies a deep-link
|
||||
(`<origin>/data/<recordtype>/<id>`) to the clipboard; double-click opens
|
||||
the link in a new tab.
|
||||
2. **Name cell** — second column, titled after the record type; shows the
|
||||
entity name truncated at 80 chars with full-name tooltip on hover.
|
||||
|
||||
Support these cells with a landing page (`/data/[type]/[id]`) that renders
|
||||
a key-value view of every field in the record, each key decorated with a
|
||||
`<help-tip>` (one-sentence description + link to the relevant help-doc
|
||||
section).
|
||||
|
||||
Pilot all three features on the **Token Cost** page, then extend to other
|
||||
entity tables in later workplans.
|
||||
|
||||
## Background
|
||||
|
||||
Current entity tables show only IDs (often truncated UUIDs) with no way to
|
||||
navigate to a record detail view or discover what a field means. For a
|
||||
person reviewing agent activity the tables are hard to parse. Providing a
|
||||
running reference number, an entity name column, and a click-through detail
|
||||
page substantially lowers the cognitive load.
|
||||
|
||||
The `<help-tip>` custom element already exists in
|
||||
`src/components/help-tip.js` and is used on several pages — the field-help
|
||||
registry will reuse it as the rendering layer.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
The pilot targets the Token Cost page. Observable Framework supports
|
||||
parameterised pages via `[param].md` file naming. The API already exposes
|
||||
`GET /token-events/?task_id=…` and `GET /token-events/summary/`; a
|
||||
per-record GET endpoint is needed before the landing page can work.
|
||||
|
||||
### Component layer
|
||||
- `src/components/ref-cell.js` — exports `refCell(index, type, id)` that
|
||||
returns an `HTMLElement` with click/dblclick handlers.
|
||||
- `src/components/field-help.js` — exports `FIELD_HELP` map and
|
||||
`fieldRow(key, value)` helper that wraps the key in `<help-tip>`.
|
||||
|
||||
### API layer
|
||||
- `GET /token-events/{event_id}` — returns `TokenEventRead` for a single
|
||||
event; needed by the landing page data loader.
|
||||
|
||||
### Dashboard pages
|
||||
- `src/data/token-events/[id].json.py` — data loader that calls the new
|
||||
single-event endpoint; returns JSON for the landing page.
|
||||
- `src/data/token-events/[id].md` — landing page: key-value table, each
|
||||
row built with `fieldRow()`.
|
||||
- `src/token-cost.md` — updated: REF column (refCell) and Name column
|
||||
added to By Repo, By Workplan, and Top Tasks tables.
|
||||
|
||||
## Tasks
|
||||
|
||||
```task
|
||||
id: T01
|
||||
title: "REF cell component"
|
||||
status: done
|
||||
priority: high
|
||||
description: >
|
||||
Create src/components/ref-cell.js.
|
||||
Export refCell(index, recordType, id) → HTMLElement.
|
||||
- Displays 1-based index as monospace text, cursor:pointer.
|
||||
- Single click: copies `${location.origin}/data/${recordType}/${id}` to
|
||||
clipboard; briefly flashes "Copied!" as a transient tooltip using the
|
||||
existing help-tip positioning pattern.
|
||||
- Double click: window.open(deeplink, '_blank').
|
||||
- No external dependencies; plain DOM.
|
||||
state_hub_task_id: "8ee527cf-436b-4bab-bdb8-406314a38d99"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T02
|
||||
title: "GET /token-events/{event_id} endpoint"
|
||||
status: done
|
||||
priority: high
|
||||
description: >
|
||||
Add GET /token-events/{event_id} to api/routers/token_events.py.
|
||||
Returns TokenEventRead (already defined in schemas/token_event.py).
|
||||
404 if not found.
|
||||
No migration needed.
|
||||
Add a test in tests/test_token_events.py: get by id → 200, unknown id → 404.
|
||||
state_hub_task_id: "02c27d25-d744-4da0-9bcb-b40ada54d5a5"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T03
|
||||
title: "Field-help registry"
|
||||
status: done
|
||||
priority: medium
|
||||
description: >
|
||||
Create src/components/field-help.js.
|
||||
Export FIELD_HELP: a plain object keyed by field name.
|
||||
Each entry: { label, description, doc } — matches help-tip attributes.
|
||||
Cover at minimum all TokenEventRead fields:
|
||||
id, tokens_in, tokens_out, tokens_total, task_id, workstream_id,
|
||||
repo_id, session_id, model, agent, ref_type, ref_id, note, created_at.
|
||||
Export fieldRow(key, value) → HTMLElement (<tr>) that wraps key in a
|
||||
<help-tip> (or plain <td> if key is not in FIELD_HELP) and value in a
|
||||
second <td>.
|
||||
Import HelpTip from ./help-tip.js to ensure the custom element is
|
||||
registered.
|
||||
state_hub_task_id: "7721d884-bd70-459f-b36d-450d69aac549"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T04
|
||||
title: "Token-event landing page"
|
||||
status: done
|
||||
priority: medium
|
||||
description: >
|
||||
Create two files:
|
||||
1. src/data/token-events/[id].json.py — data loader.
|
||||
Reads `id` from argv[1]; calls GET /token-events/{id};
|
||||
exits 1 if 404 so Observable renders an error page.
|
||||
2. src/data/token-events/[id].md — landing page.
|
||||
Imports fieldRow from components/field-help.js.
|
||||
Fetches FileAttachment("token-events/{id}.json").json().
|
||||
Renders an HTML <table> with one row per field using fieldRow().
|
||||
Title: "Token Event · {id.slice(0,8)}…".
|
||||
Back link: "<a href='/token-cost'>← Token Cost</a>".
|
||||
state_hub_task_id: "dc63746d-74b3-434a-925c-1cead480198f"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T05
|
||||
title: "Apply REF and Name columns to Token Cost page"
|
||||
status: done
|
||||
priority: high
|
||||
description: >
|
||||
Update src/token-cost.md.
|
||||
Import refCell from ./components/ref-cell.js.
|
||||
For each of the three entity tables (By Repo, By Workplan, Top Tasks):
|
||||
- Prepend a "REF" column using refCell(i+1, recordType, row.id).
|
||||
Record types: "repos" for By Repo (using repo_id), "workstreams" for
|
||||
By Workplan (using scope_id), "token-events" for Top Tasks (using
|
||||
task_id — note: links to the task landing page, not a token event page,
|
||||
until T04 is done; use recordType "tasks").
|
||||
- Add a Name column as the second data column:
|
||||
- By Repo: repo_slug (no truncation needed, slugs are short)
|
||||
- By Workplan: scope_id displayed as first 8 chars + "…" (unchanged)
|
||||
→ replace with workstream title if available; for now show scope_id
|
||||
truncated to 36 chars with full UUID tooltip.
|
||||
- Top Tasks: task_id truncated to 80 chars with full-id tooltip.
|
||||
Keep all existing columns unchanged.
|
||||
state_hub_task_id: "3225cc6c-2574-41e9-b8fd-e5e703a9dd7c"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T07
|
||||
title: "Repo filter dropdown on Token Cost page"
|
||||
status: done
|
||||
priority: high
|
||||
description: >
|
||||
Add a "Filter by repo" select element directly below the Token Cost page
|
||||
heading (above all three tables). Populate options from the already-fetched
|
||||
/repos/ data (plus an "All repos" default at the top). When a repo is selected,
|
||||
re-render all three tables (By Repo, By Workplan, Top Tasks) showing only rows
|
||||
whose data is attributed to that repo. The individual-events data (fetched for
|
||||
wsMap/taskMap) is already keyed by repo_id on each event — use that to filter
|
||||
By Workplan and Top Tasks rows client-side. By Repo always shows at most the
|
||||
selected repo (one row). If "All repos" is selected, all rows are shown as
|
||||
before. Implement filtering as a reactive variable that triggers table redraws.
|
||||
state_hub_task_id: "c01cead4-ff9c-4533-b3d8-9f7554387771"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T08
|
||||
title: "Sort order dropdown on Token Cost page"
|
||||
status: done
|
||||
priority: high
|
||||
description: >
|
||||
Add a "Sort by" select element immediately to the right of the repo filter
|
||||
dropdown. Options (in display order):
|
||||
"Tokens Total" (default — current server-side order)
|
||||
"Tokens In"
|
||||
"Tokens Out"
|
||||
"Event Count"
|
||||
"Most Recent"
|
||||
Implement as a client-side sort applied after filtering and before slicing for
|
||||
max-results. For the first four options sort descending by the named field.
|
||||
For "Most Recent", sort each table's rows by the most recent created_at among
|
||||
the individual token events belonging to that row's group (repo/workstream/task).
|
||||
Derive a lastEventAt lookup map from the already-fetched /token-events/ data;
|
||||
rows with no events sort last. The sort applies uniformly to all three tables.
|
||||
state_hub_task_id: "84183245-5016-4d87-ad6a-9cd5f6873245"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T09
|
||||
title: "Max results dropdown on Token Cost page"
|
||||
status: done
|
||||
priority: medium
|
||||
description: >
|
||||
Add a "Show" select element immediately to the right of the sort dropdown.
|
||||
Options: 10, 20, 50, 100, 500. Default: 20.
|
||||
After filtering and sorting each table's data array, slice to at most N rows
|
||||
before rendering. Display the total available count beneath or beside each
|
||||
table when the table is truncated (e.g. "Showing 20 of 47"). The limit applies
|
||||
independently per table (each table may have different totals). No API change
|
||||
needed — client-side slice of the already-fetched arrays.
|
||||
state_hub_task_id: "3ef43135-fb65-4cca-b8c3-4c7eeb52107c"
|
||||
```
|
||||
|
||||
```task
|
||||
id: T06
|
||||
title: "Consistency gate and docs update"
|
||||
status: done
|
||||
priority: low
|
||||
description: >
|
||||
1. Run `cd state-hub && make test` — all token_events tests must pass.
|
||||
2. Run `make fix-consistency REPO=the-custodian`.
|
||||
3. Add a one-paragraph entry to src/docs/reference.md describing the
|
||||
/data/<type>/<id> URL scheme and the REF column convention.
|
||||
state_hub_task_id: "107cb5bb-ff0c-4c97-af04-2cdeff11f0b2"
|
||||
```
|
||||
|
||||
## Amendments & Improvements
|
||||
|
||||
Post-completion fixes and enhancements discovered during manual testing.
|
||||
|
||||
### A01 — amendment — Deep-link URL prefix wrong
|
||||
|
||||
`ref-cell.js` generated deep-links as `<origin>/data/<type>/<id>` but
|
||||
Observable Framework serves pages at `/<type>/<id>` (the `src/data/`
|
||||
directory is for data loaders, not pages). Fixed by removing the `/data/`
|
||||
prefix from the `deepLink` construction in `ref-cell.js`. The T01
|
||||
description was also inaccurate in stating the `/data/` path.
|
||||
|
||||
### A02 — amendment — FileAttachment rejects template literals
|
||||
|
||||
T04 used `` FileAttachment(`../data/token-events/${eventId}.json`) `` in
|
||||
the landing page. Observable Framework requires `FileAttachment` to be
|
||||
called with a single **literal** string (it processes them statically at
|
||||
compile time). This caused a `SyntaxError` that also aborted the cell
|
||||
defining `eventId`, cascading into a `RuntimeError: wsId is not defined`
|
||||
on the next cell. Fixed by replacing all `FileAttachment` calls in landing
|
||||
pages with direct `fetch(${API}/...)` calls.
|
||||
|
||||
### A03 — amendment — Workstream and repo landing pages missing
|
||||
|
||||
T04 only created a token-event landing page. The By Workplan and By Repo
|
||||
tables also had REF links (to `/workstreams/<id>` and `/repos/<slug>`),
|
||||
both returning 404. Added:
|
||||
- `src/workstreams/[id].md` — fetches `GET /workstreams/{id}`
|
||||
- `src/repos/[slug].md` — fetches `GET /repos/{slug}/`
|
||||
- Corresponding unused data loaders left in `src/data/` for reference.
|
||||
|
||||
### A04 — amendment — Repos router uses slug, not UUID
|
||||
|
||||
T05 passed `repo_id` (UUID) to `refCell` for the By Repo table, but the
|
||||
repos API uses slug-based routing (`GET /repos/{slug}/`). Passing a UUID
|
||||
returned 404. Fixed by passing `repo_slug` to `refCell` so the deep-link
|
||||
resolves correctly.
|
||||
|
||||
### A05 — amendment — Top Tasks refCell used wrong record type
|
||||
|
||||
T05 specified `"token-events"` as the record type for Top Tasks, but the
|
||||
column contains `task_id` (a task UUID, not a token-event UUID), so the
|
||||
landing page returned "Token event not found". Fixed by:
|
||||
- Changing record type to `"tasks"` in the `refCell` call.
|
||||
- Creating `src/tasks/[id].md` (fetches `GET /tasks/{task_id}`).
|
||||
|
||||
### I02 — improvement — Entity FK fields on detail pages link to their targets
|
||||
|
||||
On every detail page (token-event, task, workstream, repo), fields that hold
|
||||
a foreign-key UUID (`task_id`, `workstream_id`, `repo_id`) now render as
|
||||
clickable links with an async-loaded bubble-help showing the entity title.
|
||||
|
||||
Implementation:
|
||||
- `GET /repos/by-id/{repo_id}` added to `api/routers/repos.py` — UUID
|
||||
lookup needed because the existing repos router uses slug routing.
|
||||
- `FIELD_LINKS` registry added to `src/components/field-help.js` mapping
|
||||
each FK field to `{apiUrl, getUrl, getTitle}` resolution rules.
|
||||
`getUrl` receives `(id, data)` so slug-routed entities can derive their
|
||||
page URL from the fetched entity (e.g. `repo_id` → `/repos/{data.slug}`).
|
||||
- `_linkCell(key, id)` helper added: renders a short-UUID link immediately,
|
||||
then fetches the entity asynchronously and wraps the anchor in a
|
||||
`<help-tip label="{title}" description="{type} · {full-uuid}">` once
|
||||
the data arrives.
|
||||
- `fieldRow` updated to dispatch to `_linkCell` whenever the field key is
|
||||
in `FIELD_LINKS` and the value is non-null.
|
||||
|
||||
### I05 — improvement — Max results dropdown
|
||||
|
||||
A "Show" select (10 / 20 / 50 / 100 / 500, default 20) sits to the right of
|
||||
the sort dropdown. After filtering and sorting, each table is sliced to at most
|
||||
N rows. A "Showing M of N" note appears below any truncated table.
|
||||
|
||||
### I04 — improvement — Sort order dropdown
|
||||
|
||||
A "Sort by" select sits to the right of the repo filter. Options: Tokens Total
|
||||
(default), Tokens In, Tokens Out, Event Count, Most Recent. Sorting is applied
|
||||
client-side after filtering and before the max-results slice. "Most Recent"
|
||||
sorts by the maximum `created_at` among the events in each group, derived from
|
||||
the already-fetched individual events data.
|
||||
|
||||
### I03 — improvement — Repo filter dropdown
|
||||
|
||||
A "Filter by repo" select appears directly below the Token Cost heading. Options
|
||||
come from the already-fetched `/repos/` list plus "All repos" at the top.
|
||||
Selecting a repo filters all three tables client-side to show only rows
|
||||
attributable to that repo. No API change needed.
|
||||
|
||||
### I01 — improvement — Workstream and task Name columns show titles
|
||||
|
||||
T05 originally showed truncated UUIDs in the Workstream and Task name
|
||||
columns (the summary data carries only IDs, not titles). Improved by
|
||||
fetching `/workstreams/` and `/tasks/` in parallel with the token-event
|
||||
poll and building lookup maps (`wsMap`, `taskMap`). The Name column now
|
||||
displays `workstream.title` and `task.title` (truncated to 80 chars) with
|
||||
the full UUID as tooltip.
|
||||
Reference in New Issue
Block a user