STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
metered, deprecated no-op), delivery receipts on the scoped unread inbox,
POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
dashboard standing-notices panel.
- T06: 17 new tests; full suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
This commit is contained in:
parent
740068fcf1
commit
ef541f58cf
18 changed files with 1256 additions and 52 deletions
15
dashboard/src/data/notices.json.py
Executable file
15
dashboard/src/data/notices.json.py
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches /messages/notices (live standing notices)."""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/messages/notices", 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), "notices": []}))
|
||||
|
|
@ -68,6 +68,7 @@ Current loaders:
|
|||
| `decisions.json.py` | `/decisions/` |
|
||||
| `domains.json.py` | `/domains/` |
|
||||
| `messages.json.py` | `/messages/` |
|
||||
| `notices.json.py` | `/messages/notices` |
|
||||
| `progress.json.py` | `/progress/` |
|
||||
| `repos.json.py` | `/repos/` |
|
||||
| `sbom.json.py` | `/sbom/aggregated` |
|
||||
|
|
|
|||
|
|
@ -11,14 +11,16 @@ import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
|||
const inboxState = (async function*() {
|
||||
let failures = 0;
|
||||
while (true) {
|
||||
let messages = [], ok = false;
|
||||
let messages = [], notices = [], ok = false;
|
||||
try {
|
||||
const resp = await apiFetch("/messages/?limit=100");
|
||||
ok = resp.ok;
|
||||
if (ok) messages = await resp.json();
|
||||
const nresp = await apiFetch("/messages/notices");
|
||||
if (nresp.ok) notices = await nresp.json();
|
||||
} catch {}
|
||||
failures = ok ? 0 : failures + 1;
|
||||
yield {messages, ok, ts: new Date()};
|
||||
yield {messages, notices, ok, ts: new Date()};
|
||||
await waitForVisible(pollDelay({ok, failures}));
|
||||
}
|
||||
})();
|
||||
|
|
@ -26,12 +28,17 @@ const inboxState = (async function*() {
|
|||
|
||||
```js
|
||||
const messages = inboxState.messages ?? [];
|
||||
const notices = inboxState.notices ?? [];
|
||||
const _ok = inboxState.ok ?? false;
|
||||
const _ts = inboxState.ts;
|
||||
|
||||
const unread = messages.filter(m => !m.read_at && !m.archived_at);
|
||||
const read = messages.filter(m => m.read_at && !m.archived_at);
|
||||
const archived = messages.filter(m => m.archived_at);
|
||||
// Broadcast read state is per reader (STATE-WP-0093): the dashboard has no
|
||||
// reader identity, so live broadcasts get their own section.
|
||||
const isBc = m => m.to_agent === "broadcast";
|
||||
const unread = messages.filter(m => !isBc(m) && !m.read_at && !m.archived_at);
|
||||
const read = messages.filter(m => !isBc(m) && m.read_at && !m.archived_at);
|
||||
const broadcasts = messages.filter(m => isBc(m) && !m.archived_at);
|
||||
const archived = messages.filter(m => m.archived_at);
|
||||
|
||||
// Group unread by agent for KPI
|
||||
const agentCounts = {};
|
||||
|
|
@ -55,6 +62,12 @@ const _kpiBox = html`<div class="kpi-infobox">
|
|||
<div class="kpi-row-value" style="color:${unread.length > 0 ? '#d97706' : 'inherit'}">${unread.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">standing notices</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value" style="font-size:1rem">${notices.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">total</span>
|
||||
<div class="kpi-row-right">
|
||||
|
|
@ -85,6 +98,39 @@ Inter-agent coordination messages. Agents send messages via `send_message()` MCP
|
|||
|
||||
---
|
||||
|
||||
## Standing notices
|
||||
|
||||
Live `standing` broadcasts stay in every agent's inbox until that agent
|
||||
acknowledges them (`POST /messages/{id}/ack`). Repositories listed as not yet
|
||||
acknowledged are the ones still out of date.
|
||||
|
||||
```js
|
||||
function repoList(label, slugs, color) {
|
||||
if (!slugs.length) return "";
|
||||
return html`<details class="notice-list"><summary style="color:${color}">${label} (${slugs.length})</summary>
|
||||
<div class="notice-slugs">${slugs.join(", ")}</div></details>`;
|
||||
}
|
||||
|
||||
if (notices.length === 0) {
|
||||
display(html`<p class="dim">No live standing notices.</p>`);
|
||||
} else {
|
||||
display(html`<div class="msg-list">${notices.map(n => html`<div class="msg-card" style="border-left-color:#7c3aed">
|
||||
<div class="msg-header">
|
||||
<span class="msg-from">${n.from_agent}</span>
|
||||
<span class="msg-kind">standing</span>
|
||||
<span class="msg-time">expires ${n.expires_at ? new Date(n.expires_at).toLocaleDateString() : "never"}</span>
|
||||
</div>
|
||||
<div class="msg-subject">${n.subject}</div>
|
||||
<div class="notice-counts">${n.acked_count} acknowledged · ${n.delivered_only_count} delivered, not acknowledged · ${n.unreached_count} not reached</div>
|
||||
${repoList("Not yet acknowledged — delivered", n.delivered_only, "#d97706")}
|
||||
${repoList("Not yet acknowledged — never reached", n.unreached, "#dc2626")}
|
||||
${repoList("Acknowledged", n.acknowledged, "#059669")}
|
||||
</div>`)}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unread
|
||||
|
||||
```js
|
||||
|
|
@ -96,7 +142,7 @@ function fmtDate(s) {
|
|||
|
||||
function renderMessage(m, showMarkRead = false) {
|
||||
const isBroadcast = m.to_agent === "broadcast";
|
||||
const borderColor = !m.read_at ? "#d97706" : "#6b7280";
|
||||
const borderColor = isBroadcast ? "#7c3aed" : (!m.read_at ? "#d97706" : "#6b7280");
|
||||
|
||||
async function onMarkRead() {
|
||||
await fetch(`${API}/messages/${m.id}/read`, {method: "PATCH"});
|
||||
|
|
@ -111,9 +157,10 @@ function renderMessage(m, showMarkRead = false) {
|
|||
<span class="msg-from">${m.from_agent}</span>
|
||||
<span class="msg-arrow">→</span>
|
||||
<span class="msg-to ${isBroadcast ? 'msg-broadcast' : ''}">${m.to_agent}</span>
|
||||
${isBroadcast ? html`<span class="msg-kind">${m.kind}</span>` : ""}
|
||||
<span class="msg-time">${fmtDate(m.created_at)}</span>
|
||||
<div class="msg-actions">
|
||||
${showMarkRead ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
|
||||
${showMarkRead && !isBroadcast ? html`<button class="msg-btn msg-btn-read" onclick=${onMarkRead}>Mark read</button>` : ""}
|
||||
<button class="msg-btn msg-btn-archive" onclick=${onArchive}>Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -135,6 +182,18 @@ if (unread.length === 0) {
|
|||
|
||||
---
|
||||
|
||||
## Broadcasts
|
||||
|
||||
```js
|
||||
if (broadcasts.length === 0) {
|
||||
display(html`<p class="dim">No live broadcasts.</p>`);
|
||||
} else {
|
||||
display(html`<div class="msg-list">${broadcasts.map(m => renderMessage(m, false))}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Read
|
||||
|
||||
```js
|
||||
|
|
@ -186,4 +245,9 @@ if (archived.length === 0) {
|
|||
.msg-body { white-space: pre-wrap; margin: 0.4rem 0 0; font-family: var(--mono); font-size: 0.8rem; background: var(--theme-background); padding: 0.5rem; border-radius: 4px; }
|
||||
.msg-thread { font-size: 0.7rem; color: var(--theme-foreground-muted, #999); margin-top: 0.2rem; }
|
||||
.dim { color: gray; font-style: italic; }
|
||||
.msg-kind { font-size: 0.7rem; font-weight: 600; color: #7c3aed; border: 1px solid #c4b5fd; border-radius: 4px; padding: 0 0.3rem; }
|
||||
.notice-counts { font-size: 0.8rem; color: var(--theme-foreground-muted, #555); margin: 0.2rem 0; }
|
||||
.notice-list { font-size: 0.8rem; margin-top: 0.2rem; }
|
||||
.notice-list summary { cursor: pointer; font-weight: 600; }
|
||||
.notice-slugs { font-family: var(--mono); font-size: 0.75rem; padding: 0.3rem 0; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue