Compare commits
19 Commits
2fb9db15b1
...
c9e240b068
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e240b068 | ||
|
|
6e81500b74 | ||
|
|
964ddff2f2 | ||
|
|
5dd5806640 | ||
|
|
2a57588797 | ||
|
|
2b742706f2 | ||
|
|
b09e4c78d2 | ||
|
|
30b889acd9 | ||
|
|
9553461130 | ||
|
|
e5071f8750 | ||
|
|
4c46e361a9 | ||
|
|
a34d0ea870 | ||
|
|
f9c7c2ae3c | ||
|
|
08667637e6 | ||
|
|
f0c7be25f4 | ||
|
|
941c20d12a | ||
|
|
2c2193ec55 | ||
|
|
3f1fb76a84 | ||
|
|
7922eeb8e8 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ dist/
|
||||
coverage/
|
||||
.agenthub/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
File diff suppressed because one or more lines are too long
10
assets/logo.svg
Normal file
10
assets/logo.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#38bdf8"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
|
||||
<circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 368 B |
928
docs/superpowers/plans/2026-07-20-board-v2-redesign.md
Normal file
928
docs/superpowers/plans/2026-07-20-board-v2-redesign.md
Normal file
@ -0,0 +1,928 @@
|
||||
# AgentHub Board v2 Redesign — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Die Board-Seite (`/board`) bekommt das freigegebene v2-Redesign (Glass-Dashboard, animierte KPIs, Tab-Sidebar, Live-Feed, Logo + Splash) als modularisierte Neuimplementierung von `src/server/board.ts` — ohne Funktionsverlust.
|
||||
|
||||
**Architecture:** Fastify rendert weiterhin einen HTML-String beim Serverstart (kein Build-Step, kein Framework). `src/server/board.ts` (2098 Zeilen Monolith) wird ersetzt durch `src/server/board/` mit Fokus-Modulen. Browser-Logik, die berechenbar ist (KPI-Modelle, Chart-Pfade, Chip-Kappung), liegt als **reine TypeScript-Funktionen** in `viewmodel.ts` — diese werden per `fn.toString()` in das Inline-`<script>` injiziert und sind so mit vitest direkt testbar. Bestehende Interaktions-JS (Drag&Drop, SSE, Live-Konsole, Modals, Budget-Baseline) wird aus dem alten `board.ts` portiert.
|
||||
|
||||
**Tech Stack:** TypeScript (nodenext, `.js`-Importe), Fastify, Vanilla JS + SSE im Browser, vitest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md`
|
||||
**Visuelle Referenz (freigegebenes Mockup, enthält produktionsnahes CSS/HTML):** `.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html` — die `.v25`-Klasse ist beim Portieren durch `.b2` (Board-v2-Namespace) zu ersetzen, Farbwerte bleiben identisch.
|
||||
|
||||
**Wichtige Datenfakten (verifiziert):**
|
||||
- `/budget` → `BudgetReport { agents: AgentBudget[], totals, assumptions, generatedAt }` — **keine Tageshistorie**. Das Verlauf-Chart zeigt daher **erledigte Tasks/Tag** (aus `/tasks` clientseitig ableitbar), nicht Tokens.
|
||||
- Budget-Reset ist rein clientseitig (`writeBaseline(BUDGET)` + localStorage), kein API-Call — aus altem `board.ts:1739-1749` portieren.
|
||||
- Session/Total-Umschaltung existiert in v1 (`budgetMode`, localStorage `agenthub-budget-mode`); Sub-Tabs Token/Kosten existieren als `data-donut-tab` (`board.ts:1732-1737`) — beides übernehmen.
|
||||
- `routes.ts:16` importiert `renderBoardHtml` aus `./board.js`; der Import wird auf `./board/index.js` geändert (Verzeichnis statt Datei).
|
||||
- Tests laufen mit `npx vitest run tests/<file>.test.ts` (Repo nutzt vitest, Tests importieren aus `../src/...js`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Logo-Asset + ViewModel-Helper (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `assets/logo.svg`
|
||||
- Create: `src/server/board/viewmodel.ts`
|
||||
- Test: `tests/boardV2-viewmodel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Logo-Datei anlegen**
|
||||
|
||||
`assets/logo.svg` (Hexagon-Gradient wie im freigegebenen Mockup):
|
||||
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#38bdf8"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#g)" d="M12 2l8.5 5v10L12 22l-8.5-5V7z"/>
|
||||
<circle cx="12" cy="12" r="3.2" fill="#0e1226"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Failing Tests für die ViewModel-Helper schreiben**
|
||||
|
||||
`tests/boardV2-viewmodel.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
capChips, doneStats, backlogSeries, throughputSeries, areaPath,
|
||||
type KpiTask,
|
||||
} from '../src/server/board/viewmodel.js';
|
||||
|
||||
const day = 24 * 3600 * 1000;
|
||||
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();
|
||||
|
||||
describe('capChips', () => {
|
||||
it('shows all chips when at most max', () => {
|
||||
const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
|
||||
expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
|
||||
});
|
||||
it('caps at max and reports the hidden count', () => {
|
||||
const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
|
||||
const r = capChips(chips, 3);
|
||||
expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
|
||||
expect(r.hidden).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneStats', () => {
|
||||
it('computes share and weekly count, excluding cancelled from total', () => {
|
||||
const tasks: KpiTask[] = [
|
||||
{ id: '1', status: 'done', updatedAt: iso(2 * day) },
|
||||
{ id: '2', status: 'done', updatedAt: iso(10 * day) },
|
||||
{ id: '3', status: 'open' },
|
||||
{ id: '4', status: 'cancelled' },
|
||||
];
|
||||
const r = doneStats(tasks);
|
||||
expect(r.done).toBe(2);
|
||||
expect(r.total).toBe(3); // cancelled excluded
|
||||
expect(r.pct).toBe(67);
|
||||
expect(r.doneThisWeek).toBe(1);
|
||||
});
|
||||
it('handles empty input', () => {
|
||||
expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlogSeries', () => {
|
||||
it('returns one value per day, oldest first, ending today', () => {
|
||||
const tasks: KpiTask[] = [
|
||||
{ id: '1', status: 'open', createdAt: iso(3 * day) },
|
||||
{ id: '2', status: 'done', createdAt: iso(10 * day), updatedAt: iso(1 * day) },
|
||||
];
|
||||
const s = backlogSeries(tasks, 14);
|
||||
expect(s).toHaveLength(14);
|
||||
expect(s[13]).toBe(1); // today: only the open task is backlog
|
||||
expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
|
||||
});
|
||||
});
|
||||
|
||||
describe('throughputSeries', () => {
|
||||
it('counts done tasks per day', () => {
|
||||
const tasks: KpiTask[] = [
|
||||
{ id: '1', status: 'done', updatedAt: iso(0) },
|
||||
{ id: '2', status: 'done', updatedAt: iso(0) },
|
||||
{ id: '3', status: 'done', updatedAt: iso(5 * day) },
|
||||
{ id: '4', status: 'open', updatedAt: iso(0) },
|
||||
];
|
||||
const s = throughputSeries(tasks, 14);
|
||||
expect(s).toHaveLength(14);
|
||||
expect(s[13]).toBe(2); // today
|
||||
expect(s[8]).toBe(1); // 5 days ago
|
||||
expect(s.reduce((a, b) => a + b, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areaPath', () => {
|
||||
it('builds line and area paths scaled to width/height', () => {
|
||||
const { line, area } = areaPath([0, 5, 10], 100, 50);
|
||||
expect(line).toBe('M0,50 L50,25 L100,0');
|
||||
expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
|
||||
});
|
||||
it('flattens when all values are equal (no division by zero)', () => {
|
||||
const { line } = areaPath([3, 3, 3], 90, 30);
|
||||
expect(line).toBe('M0,15 L45,15 L90,15');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tests laufen lassen und scheitern sehen**
|
||||
|
||||
Run: `npx vitest run tests/boardV2-viewmodel.test.ts`
|
||||
Expected: FAIL — `Cannot find module '../src/server/board/viewmodel.js'`
|
||||
|
||||
- [ ] **Step 4: `src/server/board/viewmodel.ts` implementieren**
|
||||
|
||||
Komplette Datei. Alle Funktionen sind bewusst **frei von Imports und Closures** (sie werden per `.toString()` in die Browser-Seite injiziert); `declare` nur für Typen:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Pure, dependency-free helpers for the board v2 UI.
|
||||
* These run BOTH in vitest (server-side) and in the browser — board/index.ts
|
||||
* injects them into the inline <script> via Function.prototype.toString().
|
||||
* Therefore: no imports, no closures over module state, ES2019 syntax only.
|
||||
*/
|
||||
|
||||
export interface KpiTask {
|
||||
id: string;
|
||||
status?: string;
|
||||
assignedTo?: string;
|
||||
reviewer?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
claimedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentChip {
|
||||
name: string;
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 3600 * 1000;
|
||||
|
||||
/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
|
||||
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
|
||||
const visible = chips.slice(0, Math.max(0, max));
|
||||
return { visible, hidden: chips.length - visible.length };
|
||||
}
|
||||
|
||||
/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
|
||||
export function doneStats(
|
||||
tasks: KpiTask[],
|
||||
now?: number,
|
||||
): { done: number; total: number; pct: number; doneThisWeek: number } {
|
||||
const t0 = now ?? Date.now();
|
||||
const relevant = tasks.filter((t) => t.status !== 'cancelled');
|
||||
const doneTasks = relevant.filter((t) => t.status === 'done');
|
||||
const weekAgo = t0 - 7 * DAY_MS;
|
||||
const doneThisWeek = doneTasks.filter(
|
||||
(t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
|
||||
).length;
|
||||
const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
|
||||
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
|
||||
}
|
||||
|
||||
function dayStart(ts: number): number {
|
||||
const d = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backlog (open) count for each of the last `days` days, oldest first.
|
||||
* Approximation: a task counts as backlog on day D when it existed by end of D
|
||||
* and was not yet moved out of 'open' (non-open tasks use updatedAt as the
|
||||
* transition timestamp; still-open tasks are backlog on every day since creation).
|
||||
*/
|
||||
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
|
||||
const today = dayStart(now ?? Date.now());
|
||||
const out: number[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const endOfDay = today - i * DAY_MS + DAY_MS - 1;
|
||||
let count = 0;
|
||||
for (const t of tasks) {
|
||||
const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
|
||||
if (created > endOfDay) continue;
|
||||
if (t.status === 'open') {
|
||||
count++;
|
||||
} else if (t.status !== 'cancelled') {
|
||||
const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
|
||||
if (left > endOfDay) count++; // was still open on that day
|
||||
}
|
||||
}
|
||||
out.push(count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Done-per-day counts for the last `days` days, oldest first. */
|
||||
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
|
||||
const today = dayStart(now ?? Date.now());
|
||||
const out: number[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const start = today - i * DAY_MS;
|
||||
const end = start + DAY_MS - 1;
|
||||
out.push(
|
||||
tasks.filter(
|
||||
(t) =>
|
||||
t.status === 'done' &&
|
||||
t.updatedAt &&
|
||||
new Date(t.updatedAt).getTime() >= start &&
|
||||
new Date(t.updatedAt).getTime() <= end,
|
||||
).length,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
|
||||
export function areaPath(
|
||||
values: number[],
|
||||
w: number,
|
||||
h: number,
|
||||
): { line: string; area: string } {
|
||||
if (values.length === 0) return { line: '', area: '' };
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const span = max - min;
|
||||
const step = values.length > 1 ? w / (values.length - 1) : 0;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = Math.round(i * step * 100) / 100;
|
||||
const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
const line = `M${pts.join(' L')}`;
|
||||
return { line, area: `${line} L${w},${h} L0,${h} Z` };
|
||||
}
|
||||
|
||||
/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
|
||||
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
|
||||
const t0 = now ?? Date.now();
|
||||
return tasks
|
||||
.filter((t) => t.status === status)
|
||||
.map((t) => {
|
||||
const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
|
||||
const since = t.claimedAt ?? t.updatedAt;
|
||||
return {
|
||||
name: name ?? '?',
|
||||
minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.name !== '?');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Tests laufen lassen — grün**
|
||||
|
||||
Run: `npx vitest run tests/boardV2-viewmodel.test.ts`
|
||||
Expected: PASS (7 Tests). Dann zusätzlich sicherstellen, dass nichts anderes bricht: `npx vitest run` (Gesamtlauf) — Expected: alle bestehenden Tests grün (die neue Datei wird noch nirgends importiert).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add assets/logo.svg src/server/board/viewmodel.ts tests/boardV2-viewmodel.test.ts
|
||||
git commit -m "feat(board): v2 viewmodel helpers + logo asset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Styles-Modul (Design-Tokens, Glass, Keyframes)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/board/styles.ts`
|
||||
- Reference (CSS-Quelle): `.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html` (`.v25`-Block)
|
||||
- Reference (zu portierende v1-Styles): `src/server/board.ts:56-845`
|
||||
|
||||
- [ ] **Step 1: Failing Test**
|
||||
|
||||
`tests/boardV2-styles.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { boardV2Css } from '../src/server/board/styles.js';
|
||||
|
||||
describe('boardV2Css', () => {
|
||||
it('contains the glass surface tokens and keyframes', () => {
|
||||
const css = boardV2Css();
|
||||
expect(css).toContain('--b2-bg');
|
||||
expect(css).toContain('rgba(255,255,255,.045)'); // glass surface
|
||||
expect(css).toContain('@keyframes b2-rise');
|
||||
expect(css).toContain('@keyframes b2-fill');
|
||||
expect(css).toContain('@keyframes b2-ping');
|
||||
});
|
||||
it('respects prefers-reduced-motion', () => {
|
||||
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npx vitest run tests/boardV2-styles.test.ts` — Expected: FAIL (Modul fehlt).
|
||||
|
||||
- [ ] **Step 2: `src/server/board/styles.ts` implementieren**
|
||||
|
||||
`export function boardV2Css(): string` gibt den kompletten CSS-Block als Template-String zurück. Aufbau:
|
||||
|
||||
1. **Tokens** (auf `:root` der Board-Seite, Präfix `--b2-`): `--b2-bg:#090c18`, `--b2-surface:rgba(255,255,255,.045)`, `--b2-border:rgba(255,255,255,.09)`, `--b2-text:#eef1f8`, `--b2-muted:#8fa0c6`, `--b2-accent:#38bdf8`, `--b2-violet:#8b5cf6`, `--b2-green:#34d399`, `--b2-amber:#fbbf24`, `--b2-open:#8b949e`, Statusfarben identisch zu v1 (`src/server/ui-shared.ts` `designTokensCss()`).
|
||||
2. **Basis**: Body-Hintergrund `radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%)`.
|
||||
3. **Komponenten**: Header, KPI-Grid (4 Karten), Chips, Progress-Bar, Spalten/Karten, Sidebar-Panels, Tabs (Über + Sub), Donut, Feed, Splash-Overlay, Modals, Toasts. Das komplette, freigegebene Komponenten-CSS steht im Mockup `.superpowers/brainstorm/22429-1784510154/content/board-v25-final.html` im `<style>`-Block — Klassenpräfix `.v25` → `.b2` umbenennen, Keyframes `rise/fill/blink/draw/d25/ping` → `b2-rise` etc., und auf volle App-Größe skalieren (Mockup ist Miniatur: Schriftgrößen/Abstände ×~1.3, Spalten-Mindesthöhe entfällt zugunsten echter Inhalte).
|
||||
4. **Aus v1 portieren** (unverändert nötig für bestehende Interaktionen): Drag&Drop-States (`.card.dragging`, Drop-Highlights), Live-Konsole in Karten, Modal-Styles, Toast-Styles, `metric-flash` — Quelle `src/server/board.ts:56-845`.
|
||||
5. **Reduced Motion** am Ende:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.b2, .b2 * { animation: none !important; transition: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tests grün**
|
||||
|
||||
Run: `npx vitest run tests/boardV2-styles.test.ts` — Expected: PASS (2 Tests).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/board/styles.ts tests/boardV2-styles.test.ts
|
||||
git commit -m "feat(board): v2 glass design system css"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Chrome-Modul (Header mit Logo + Splash Screen)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/board/chrome.ts`
|
||||
- Test: `tests/boardV2-chrome.test.ts`
|
||||
|
||||
- [ ] **Step 1: Failing Test**
|
||||
|
||||
`tests/boardV2-chrome.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';
|
||||
|
||||
describe('headerHtml', () => {
|
||||
it('renders logo, project cell with chevron, nav and new-task button', () => {
|
||||
const h = headerHtml('my-project');
|
||||
expect(h).toContain('/card-assets/../logo.svg'.replace('/card-assets/..', '')); // /logo.svg
|
||||
expect(h).toContain('my-project');
|
||||
expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
|
||||
expect(h).toContain('▾');
|
||||
expect(h).toContain('+ New task');
|
||||
expect(h).toContain('id="connDot"');
|
||||
});
|
||||
it('escapes the project name', () => {
|
||||
expect(headerHtml('<b>x</b>')).not.toContain('<b>x</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splash', () => {
|
||||
it('renders overlay with logo and wordmark', () => {
|
||||
const s = splashHtml();
|
||||
expect(s).toContain('id="b2-splash"');
|
||||
expect(s).toContain('/logo.svg');
|
||||
expect(s).toContain('agenthub');
|
||||
});
|
||||
it('fade-out script has a hard timeout and never blocks', () => {
|
||||
const js = splashJs();
|
||||
expect(js).toContain('b2-splash');
|
||||
expect(js).toContain('1500'); // hard cap in ms
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npx vitest run tests/boardV2-chrome.test.ts` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 2: `src/server/board/chrome.ts` implementieren**
|
||||
|
||||
Vollständige Datei:
|
||||
|
||||
```ts
|
||||
import { escapeHtml } from '../ui-shared.js';
|
||||
|
||||
const LOGO = '<img src="/logo.svg" alt="" width="22" height="22" class="b2-logo-img">';
|
||||
|
||||
/** Fixed app header for board v2. Project cell is visual only (dropdown = v3). */
|
||||
export function headerHtml(projectName: string): string {
|
||||
const name = escapeHtml(projectName);
|
||||
return `
|
||||
<header class="b2-hdr">
|
||||
<span class="b2-brand">${LOGO}<b>agenthub</b></span>
|
||||
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ ${name} <span class="b2-chev">▾</span></span>
|
||||
<nav class="b2-nav">
|
||||
<b>Board</b><a href="/team">Team</a><a href="/activity">Activity</a><a href="/decisions">Decisions</a>
|
||||
</nav>
|
||||
<span class="b2-hdr-right">
|
||||
<button type="button" class="b2-btn" id="newTaskBtn">+ New task</button>
|
||||
<span class="b2-live-dot" id="connDot" title="Live-Verbindung"></span>
|
||||
</span>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
|
||||
export function splashHtml(): string {
|
||||
return `
|
||||
<div id="b2-splash" class="b2-splash" aria-hidden="true">
|
||||
<div class="b2-splash-inner">
|
||||
<img src="/logo.svg" alt="agenthub" width="72" height="72" class="b2-splash-logo">
|
||||
<div class="b2-splash-word">agenthub</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script: hides the splash after the first successful refresh() or after
|
||||
* a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
|
||||
* the data layer can signal completion.
|
||||
*/
|
||||
export function splashJs(): string {
|
||||
return `
|
||||
(function () {
|
||||
var done = false;
|
||||
var el = function () { return document.getElementById('b2-splash'); };
|
||||
window.__b2SplashDone = function () {
|
||||
if (done) return;
|
||||
done = true;
|
||||
var s = el();
|
||||
if (!s) return;
|
||||
s.classList.add('b2-splash-hide');
|
||||
setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
|
||||
};
|
||||
setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
|
||||
})();`;
|
||||
}
|
||||
```
|
||||
|
||||
Hinweis: `escapeHtml` existiert bereits in `src/server/ui-shared.ts` und wird wiederverwendet. Die Splash-CSS (`b2-splash`, Fade-Out per `opacity`-Transition + `b2-splash-hide`, Logo-Puls) ist Teil von Task 2; falls dort noch nicht enthalten, hier ergänzen:
|
||||
|
||||
```css
|
||||
.b2-splash { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%);
|
||||
transition: opacity .4s ease; }
|
||||
.b2-splash-hide { opacity: 0; pointer-events: none; }
|
||||
.b2-splash-logo { animation: b2-ping 1.6s ease-in-out infinite; }
|
||||
.b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tests grün**
|
||||
|
||||
Run: `npx vitest run tests/boardV2-chrome.test.ts` — Expected: PASS (4 Tests).
|
||||
|
||||
- [ ] **Step 4: Favicon + Logo-Route sicherstellen**
|
||||
|
||||
`src/server/routes.ts`: prüfen, dass `GET /logo.svg` ausgeliefert wird. Es existiert bereits eine Read-Only-Route `GET /card-assets/*` (routes.ts:64-79, Root = Repo-`assets/`). Kleinstmögliche Ergänzung direkt daneben:
|
||||
|
||||
```ts
|
||||
app.get('/logo.svg', async (_req, reply) => {
|
||||
reply.type('image/svg+xml');
|
||||
return readFileSync(join(assetsRoot, 'logo.svg'));
|
||||
});
|
||||
```
|
||||
|
||||
(`assetsRoot` und `readFileSync`/`join` wie in der bestehenden `/card-assets/*`-Route verwendet — deren exakte lokalen Namen übernehmen.) Favicon im HTML-Head (Task 6, Assembly): `<link rel="icon" href="/logo.svg" type="image/svg+xml">`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/board/chrome.ts tests/boardV2-chrome.test.ts src/server/routes.ts
|
||||
git commit -m "feat(board): v2 header, splash screen, logo route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: KPI-Modul
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/board/kpis.ts`
|
||||
- Test: `tests/boardV2-kpis.test.ts`
|
||||
|
||||
- [ ] **Step 1: Failing Test**
|
||||
|
||||
`tests/boardV2-kpis.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kpiSkeletonHtml, kpiJs } from '../src/server/board/kpis.js';
|
||||
|
||||
describe('kpiSkeletonHtml', () => {
|
||||
it('renders the four KPI cards with stable ids', () => {
|
||||
const h = kpiSkeletonHtml();
|
||||
for (const id of ['kpiOpen', 'kpiInProgress', 'kpiReview', 'kpiDone']) {
|
||||
expect(h).toContain(`id="${id}"`);
|
||||
}
|
||||
expect(h).toContain('Open');
|
||||
expect(h).toContain('In Progress');
|
||||
expect(h).toContain('Review');
|
||||
expect(h).toContain('Done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kpiJs', () => {
|
||||
it('injects the pure helpers and an updateKpis entry point', () => {
|
||||
const js = kpiJs();
|
||||
for (const fn of ['capChips', 'doneStats', 'backlogSeries', 'areaPath', 'laneChips']) {
|
||||
expect(js).toContain(`function ${fn}`);
|
||||
}
|
||||
expect(js).toContain('window.__b2UpdateKpis');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npx vitest run tests/boardV2-kpis.test.ts` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 2: `src/server/board/kpis.ts` implementieren**
|
||||
|
||||
```ts
|
||||
import {
|
||||
capChips, doneStats, backlogSeries, areaPath, laneChips,
|
||||
} from './viewmodel.js';
|
||||
|
||||
/** Static skeleton: four KPI cards; values are filled client-side. */
|
||||
export function kpiSkeletonHtml(): string {
|
||||
return `
|
||||
<section class="b2-kpis" aria-label="Kennzahlen">
|
||||
<div class="b2-kpi b2-glass" id="kpiOpen">
|
||||
<div class="b2-lbl">Open</div>
|
||||
<div class="b2-val"><span data-k="count">–</span> <small data-k="total"></small></div>
|
||||
<div class="b2-miniarea" data-k="area"></div>
|
||||
</div>
|
||||
<div class="b2-kpi b2-glass" id="kpiInProgress">
|
||||
<div class="b2-lbl">In Progress</div>
|
||||
<div class="b2-val"><span data-k="count">–</span></div>
|
||||
<div class="b2-chips" data-k="chips"></div>
|
||||
</div>
|
||||
<div class="b2-kpi b2-glass" id="kpiReview">
|
||||
<div class="b2-lbl">Review</div>
|
||||
<div class="b2-val"><span data-k="count">–</span></div>
|
||||
<div class="b2-chips" data-k="chips"></div>
|
||||
</div>
|
||||
<div class="b2-kpi b2-glass" id="kpiDone">
|
||||
<div class="b2-lbl">Done</div>
|
||||
<div class="b2-val"><span data-k="count">–</span> <small data-k="total"></small></div>
|
||||
<div class="b2-cap-r" data-k="cap"></div>
|
||||
<div class="b2-pbar"><i data-k="bar"></i></div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script: injects the pure viewmodel helpers (toString) and defines
|
||||
* window.__b2UpdateKpis(tasks, agentColor), called by the data layer on every
|
||||
* refresh. Agent colors reuse the existing per-agent palette via the passed
|
||||
* agentColor(name) function from the columns module.
|
||||
*/
|
||||
export function kpiJs(): string {
|
||||
return `
|
||||
${capChips.toString()}
|
||||
${doneStats.toString()}
|
||||
${backlogSeries.toString()}
|
||||
${areaPath.toString()}
|
||||
${laneChips.toString()}
|
||||
window.__b2UpdateKpis = function (tasks, agentColor) {
|
||||
var DAY = 86400000;
|
||||
function setK(card, key, fn) {
|
||||
var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
|
||||
if (el) fn(el);
|
||||
}
|
||||
// Open
|
||||
var open = tasks.filter(function (t) { return t.status === 'open'; });
|
||||
setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
|
||||
setK('kpiOpen', 'total', function (el) {
|
||||
el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
|
||||
});
|
||||
setK('kpiOpen', 'area', function (el) {
|
||||
var p = areaPath(backlogSeries(tasks, 14), 120, 26);
|
||||
el.innerHTML = '<svg width="100%" height="26" viewBox="0 0 120 26" preserveAspectRatio="none">'
|
||||
+ '<path fill="rgba(148,163,184,.18)" d="' + p.area + '"/>'
|
||||
+ '<path class="b2-spark" fill="none" stroke="#94a3b8" stroke-width="1.5" d="' + p.line + '"/></svg>';
|
||||
});
|
||||
// In Progress + Review chips (max 3, then +n)
|
||||
['in_progress', 'review'].forEach(function (status) {
|
||||
var card = status === 'review' ? 'kpiReview' : 'kpiInProgress';
|
||||
var chips = laneChips(tasks, status);
|
||||
var capped = capChips(chips, 3);
|
||||
setK(card, 'count', function (el) { el.textContent = String(chips.length); });
|
||||
setK(card, 'chips', function (el) {
|
||||
el.innerHTML = capped.visible.map(function (c) {
|
||||
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
|
||||
var initial = c.name.charAt(0).toUpperCase();
|
||||
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
|
||||
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
|
||||
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
|
||||
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
|
||||
});
|
||||
});
|
||||
// Done
|
||||
var ds = doneStats(tasks);
|
||||
setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
|
||||
setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
|
||||
setK('kpiDone', 'cap', function (el) {
|
||||
el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';
|
||||
});
|
||||
setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; });
|
||||
};`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tests grün**
|
||||
|
||||
Run: `npx vitest run tests/boardV2-kpis.test.ts` — Expected: PASS (2 Tests).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/board/kpis.ts tests/boardV2-kpis.test.ts
|
||||
git commit -m "feat(board): v2 kpi cards (area chart, capped chips, done bar)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Sidebar-Modul (Budget-Tabs, Donuts, Verlauf-Chart, Live-Feed)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/board/sidebar.ts`
|
||||
- Reference (zu portierende v1-Logik): `src/server/board.ts:1548-1749` (`renderBudget`, `renderDonut`, `renderAgentBars`, `writeBaseline`, Session/Total, `data-donut-tab`, Reset-Handler)
|
||||
- Test: `tests/boardV2-sidebar.test.ts`
|
||||
|
||||
- [ ] **Step 1: Failing Test**
|
||||
|
||||
`tests/boardV2-sidebar.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';
|
||||
|
||||
describe('sidebarHtml', () => {
|
||||
it('renders budget card with über-tabs and insight as default', () => {
|
||||
const h = sidebarHtml();
|
||||
expect(h).toContain('id="b2Budget"');
|
||||
expect(h).toContain('data-otab="insight"');
|
||||
expect(h).toContain('data-otab="verlauf"');
|
||||
expect(h).toContain('id="budgetReset"');
|
||||
expect(h).toContain('id="b2Feed"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebarJs', () => {
|
||||
it('persists the über-tab, keeps v1 reset behaviour and renders throughput', () => {
|
||||
const js = sidebarJs();
|
||||
expect(js).toContain('agenthub-budget-otab'); // localStorage key
|
||||
expect(js).toContain('writeBaseline'); // v1 reset port
|
||||
expect(js).toContain('function throughputSeries');
|
||||
expect(js).toContain('__b2UpdateSidebar');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npx vitest run tests/boardV2-sidebar.test.ts` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 2: `src/server/board/sidebar.ts` implementieren**
|
||||
|
||||
```ts
|
||||
import { throughputSeries, areaPath } from './viewmodel.js';
|
||||
|
||||
/**
|
||||
* Sidebar skeleton: budget card (über-tabs Token Insights | Verlauf; insight is
|
||||
* the default) + live feed card. Donut/bars markup is rendered client-side by
|
||||
* the ported v1 renderBudget() — the skeleton only provides the mount points.
|
||||
*/
|
||||
export function sidebarHtml(): string {
|
||||
return `
|
||||
<aside class="b2-side">
|
||||
<div class="b2-panel b2-glass" id="b2Budget">
|
||||
<div class="b2-tabhead">
|
||||
<h6>Budget</h6>
|
||||
<div class="b2-otabs">
|
||||
<button type="button" data-otab="insight" class="active">Token Insights</button>
|
||||
<button type="button" data-otab="verlauf">Verlauf</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-opane="insight">
|
||||
<div class="b2-subhead">
|
||||
<div class="b2-stabs" id="donutTabs"></div>
|
||||
<div class="seg" id="budgetSeg"></div>
|
||||
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
|
||||
</div>
|
||||
<div id="budgetBody"></div>
|
||||
</div>
|
||||
<div data-opane="verlauf" hidden>
|
||||
<div id="b2Throughput"></div>
|
||||
<div class="b2-chart-tip" id="b2ThroughputTip"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b2-panel b2-glass">
|
||||
<h6>Live</h6>
|
||||
<div class="b2-feed" id="b2Feed"></div>
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script: über-tab switching (persisted in localStorage), throughput
|
||||
* area chart, live feed, plus the PORTED v1 budget code (donut, agent bars,
|
||||
* session/total, baseline reset) — see plan Task 5 Step 3 for the port source.
|
||||
*/
|
||||
export function sidebarJs(v1BudgetJs: string): string {
|
||||
return `
|
||||
${throughputSeries.toString()}
|
||||
${areaPath.toString()}
|
||||
(function () {
|
||||
var OTAB_KEY = 'agenthub-budget-otab';
|
||||
function activate(name) {
|
||||
document.querySelectorAll('[data-otab]').forEach(function (b) {
|
||||
b.classList.toggle('active', b.getAttribute('data-otab') === name);
|
||||
});
|
||||
document.querySelectorAll('[data-opane]').forEach(function (p) {
|
||||
p.hidden = p.getAttribute('data-opane') !== name;
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest && e.target.closest('[data-otab]');
|
||||
if (!btn) return;
|
||||
var name = btn.getAttribute('data-otab');
|
||||
localStorage.setItem(OTAB_KEY, name);
|
||||
activate(name);
|
||||
});
|
||||
activate(localStorage.getItem(OTAB_KEY) || 'insight');
|
||||
})();
|
||||
window.__b2RenderThroughput = function (tasks) {
|
||||
var box = document.getElementById('b2Throughput');
|
||||
var tip = document.getElementById('b2ThroughputTip');
|
||||
if (!box) return;
|
||||
var s = throughputSeries(tasks, 14);
|
||||
var p = areaPath(s, 220, 80);
|
||||
box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'
|
||||
+ '<path fill="rgba(56,189,248,.25)" d="' + p.area + '"/>'
|
||||
+ '<path class="b2-spark" fill="none" stroke="#38bdf8" stroke-width="1.6" d="' + p.line + '"/></svg>';
|
||||
if (tip) {
|
||||
var avg = s.reduce(function (a, b) { return a + b; }, 0) / s.length;
|
||||
tip.textContent = 'Erledigte Tasks · 14 Tage · Ø ' + avg.toFixed(1) + '/Tag';
|
||||
}
|
||||
};
|
||||
window.__b2FeedPush = function (html) {
|
||||
var feed = document.getElementById('b2Feed');
|
||||
if (!feed) return;
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
feed.insertBefore(div, feed.firstChild);
|
||||
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
|
||||
};
|
||||
${v1BudgetJs}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: v1-Budget-JS portieren**
|
||||
|
||||
Aus `src/server/board.ts:1548-1749` die Funktionen `renderBudget`, `renderDonut`, `renderAgentBars`, `writeBaseline`, die Session/Total-Logik (`budgetMode`, localStorage `agenthub-budget-mode`), die Sub-Tab-Logik (`data-donut-tab`, localStorage `agenthub-donut-metric`) und den Reset-Handler (`board.ts:1739-1749`) unverändert in einen String-Block übernehmen und als `v1BudgetJs` an `sidebarJs()` übergeben (beim Assembly in Task 6). Einzige Anpassung: das v1-Markup mountet jetzt in `#budgetBody` / `#donutTabs` / `#budgetSeg` statt der alten Container-IDs; die Sub-Tabs bekommen die Labels „Token" / „Kosten". Donut-Center-Text (Fix aus v1, SVG `<text>` bei cx=110 cy=122) beibehalten.
|
||||
|
||||
- [ ] **Step 4: Tests grün**
|
||||
|
||||
Run: `npx vitest run tests/boardV2-sidebar.test.ts` — Expected: PASS (2 Tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/board/sidebar.ts tests/boardV2-sidebar.test.ts
|
||||
git commit -m "feat(board): v2 sidebar with tabbed budget card, throughput chart, live feed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Assembly + Spalten/Karten/Modals-Port + Verdrahtung
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/board/index.ts`
|
||||
- Modify: `src/server/routes.ts:16` (Import auf `./board/index.js`)
|
||||
- Delete: `src/server/board.ts` (nach erfolgreichem Port)
|
||||
- Reference (Port-Quelle): `src/server/board.ts` komplett — insb. `847-973` (Skeleton), `975-2094` (JS: `taskCard()` ~1123, `refresh()` ~1373, Drag&Drop, SSE `task-log`, Modals, Toasts, `metric-flash`, Lottie-Init 2034-2094)
|
||||
|
||||
- [ ] **Step 1: Failing Test**
|
||||
|
||||
`tests/boardV2.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderBoardHtml } from '../src/server/board/index.js';
|
||||
|
||||
describe('renderBoardHtml (v2)', () => {
|
||||
const html = renderBoardHtml('demo-project');
|
||||
|
||||
it('is a full html document with favicon and splash', () => {
|
||||
expect(html).toMatch(/^<!doctype html>/i);
|
||||
expect(html).toContain('rel="icon" href="/logo.svg"');
|
||||
expect(html).toContain('id="b2-splash"');
|
||||
});
|
||||
it('contains header, kpis, columns and sidebar mount points', () => {
|
||||
for (const s of ['b2-hdr', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) {
|
||||
expect(html).toContain(s);
|
||||
}
|
||||
});
|
||||
it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => {
|
||||
for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'newTaskModal']) {
|
||||
expect(html).toContain(s);
|
||||
}
|
||||
});
|
||||
it('does not ship the old v1 metric cards or lottie kpi icons', () => {
|
||||
expect(html).not.toContain('metric-card');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run: `npx vitest run tests/boardV2.test.ts` — Expected: FAIL (Modul fehlt).
|
||||
|
||||
- [ ] **Step 2: `src/server/board/index.ts` implementieren**
|
||||
|
||||
Struktur (die Lücken `columnsJs()`/`modalsJs()` kommen aus Step 3):
|
||||
|
||||
```ts
|
||||
import { boardV2Css } from './styles.js';
|
||||
import { headerHtml, splashHtml, splashJs } from './chrome.js';
|
||||
import { kpiSkeletonHtml, kpiJs } from './kpis.js';
|
||||
import { sidebarHtml, sidebarJs } from './sidebar.js';
|
||||
import { columnsHtml, columnsJs, modalsHtml, modalsJs, dataLayerJs } from './columns.js';
|
||||
import { v1BudgetJs } from './v1Budget.js';
|
||||
|
||||
export const BOARD_COLUMNS = [
|
||||
{ key: 'open', label: 'Open' },
|
||||
{ key: 'in_progress', label: 'In Progress' },
|
||||
{ key: 'review', label: 'Review' },
|
||||
];
|
||||
|
||||
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>agenthub — Board</title>
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||
<style>${boardV2Css()}</style>
|
||||
</head>
|
||||
<body class="b2">
|
||||
${splashHtml()}
|
||||
${headerHtml(projectName)}
|
||||
<main class="b2-body">
|
||||
<div class="b2-main">
|
||||
${kpiSkeletonHtml()}
|
||||
${columnsHtml(BOARD_COLUMNS)}
|
||||
</div>
|
||||
${sidebarHtml()}
|
||||
</main>
|
||||
${modalsHtml()}
|
||||
<div id="toasts"></div>
|
||||
<script>${splashJs()}</script>
|
||||
<script>${dataLayerJs()}${kpiJs()}${sidebarJs(v1BudgetJs)}${columnsJs()}${modalsJs()}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `columns.ts` + `v1Budget.ts` aus dem alten board.ts portieren**
|
||||
|
||||
- `src/server/board/v1Budget.ts`: `export const v1BudgetJs = \`...\`` mit dem Budget-Block aus Task 5 Step 3.
|
||||
- `src/server/board/columns.ts` exportiert `columnsHtml(columns)` (3 Spalten-Skeleton, Glass-Panels, Status-Punkt + Count-Pill — Markup aus Mockup v2.5), `columnsJs()` (Port aus altem `board.ts`: `taskCard()` ~1123 inkl. Live-Konsole/Timer/Agent-Tag/Reviewer-Badge, Drag&Drop mit Race-Guard-Revert, Agent-Chip-Drop, `metric-flash`-Wertänderungspuls — **nicht** die Lottie-KPI-Icons, die entfallen), `modalsHtml()`/`modalsJs()` (New-Task-, Delete-, Task-Detail-Modal, unveränderte Logik), `dataLayerJs()` (`refresh()` ~1373: fetch `/tasks`+`/agents`, SSE `/events`, SSE `task-log`; nach jedem erfolgreichen `refresh()` Aufruf von `window.__b2UpdateKpis(tasks, agentColor)`, `window.__b2RenderThroughput(tasks)` und einmalig `window.__b2SplashDone()`; SSE-Events zusätzlich an `window.__b2FeedPush(...)` als kompakte einzeilige Meldung, z.B. `TSK-0145 → in_progress`).
|
||||
- Karten-Enter-Animation: wie v1 nur für `.card-new` (Kommentar im alten Code ~663-666 beachten), damit Poll-Updates nicht flackern.
|
||||
- `agentColor(name)`: bestehende Agenten-Farbpalette aus dem alten board.ts übernehmen (claude `#D97757`, codex `#10A37F` etc.) und dem KPI-Modul als Funktion bereitstellen.
|
||||
|
||||
- [ ] **Step 4: Verdrahtung + altes board.ts löschen**
|
||||
|
||||
1. `src/server/routes.ts:16`: `import { renderBoardHtml } from './board.js';` → `import { renderBoardHtml } from './board/index.js';`
|
||||
2. `src/server/board.ts` löschen.
|
||||
3. Prüfen, dass keine andere Datei mehr `./board.js` (Datei) importiert: `grep -rn "from '.*board\.js'" src tests` — erwartete Treffer nur `board/index.js` und `board/*.js`-interne Importe.
|
||||
|
||||
- [ ] **Step 5: Build + volle Testsuite**
|
||||
|
||||
Run: `npx tsc --noEmit` — Expected: keine Fehler.
|
||||
Run: `npx vitest run` — Expected: gesamte Suite grün (inkl. `tests/boardV2*.test.ts`; bestehende Tests, die v1-board-Strings assertieren, sind ggf. an die neuen Mount-Points anzupassen — nur die Assertions aktualisieren, keine Testlogik ändern).
|
||||
|
||||
- [ ] **Step 6: Manueller Check**
|
||||
|
||||
```bash
|
||||
pnpm build 2>/dev/null || npx tsc
|
||||
node bin/agenthub.js server start
|
||||
# Browser: http://127.0.0.1:3377/board
|
||||
```
|
||||
|
||||
Checkliste: Splash erscheint und faded aus · KPI-Karten füllen sich (Chips max 3 + „+n") · Open-Area-Chart zeichnet sich · Done-Bar animiert · Über-Tabs (Token Insights ↔ Verlauf) + Sub-Tabs (Token ↔ Kosten) + Session/Total + Reset funktionieren wie v1 · Drag&Drop Statuswechsel · Agent-Zuweisung per Drop · Live-Konsole läuft · Live-Feed zeigt Events · SSE-Dot grün · `prefers-reduced-motion` (DevTools-Emulation) schaltet Animationen ab.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/server/board/ src/server/routes.ts tests/boardV2.test.ts
|
||||
git rm src/server/board.ts 2>/dev/null || true
|
||||
git commit -m "feat(board): replace v1 monolith with modular v2 glass dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (ausgeführt)
|
||||
|
||||
- **Spec-Abdeckung:** Logo (T1/T3), Splash (T3), Header m. Projektzelle (T3), KPIs inkl. aller 4 Karten-Designs (T1+T4), Spalten/Karten/Drag&Drop/Konsole/Modals (T6), Sidebar-Tabs inkl. Reset + Session/Total + Sub-Tabs (T5), Verlauf-Chart (T5, als Tasks/Tag — `/budget` hat verifiziert keine Historie; Spec-Fallback greift), Live-Feed (T5/T6), Modularisierung (T2-T6), reduced-motion (T2), Lottie-Entfall dokumentiert (T6 Step 1 Test + Step 3), Multi-Projekt nur visuell (T3, v3-Spec). Keine Lücken.
|
||||
- **Placeholder-Scan:** Task 2 Step 2 und Task 6 Step 3 sind bewusst Port-Anweisungen mit exakten Quell-Referenzen (Datei + Zeilen) statt dupliziertem Code — der Code existiert bereits in `src/server/board.ts` bzw. im Mockup; keine TBDs.
|
||||
- **Typ-Konsistenz:** `capChips`, `doneStats`, `backlogSeries`, `throughputSeries`, `areaPath`, `laneChips`, `KpiTask`, `AgentChip` in T1 definiert und in T4/T5 identisch verwendet; `window.__b2UpdateKpis` / `__b2RenderThroughput` / `__b2FeedPush` / `__b2SplashDone` konsistent zwischen T3/T4/T5/T6.
|
||||
104
docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md
Normal file
104
docs/superpowers/specs/2026-07-20-board-v2-redesign-design.md
Normal file
@ -0,0 +1,104 @@
|
||||
# AgentHub Board v2 — Redesign (Spec)
|
||||
|
||||
Datum: 2026-07-20
|
||||
Status: freigegeben durch User (Mockup-Iterationen v2 → v2.5 im Visual Companion)
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Board-Seite (`/board`) sieht aus wie ein echtes, modernes Dashboard: Glas-Optik (Richtung „Modern SaaS / Glass"), dezente Animationen, mehr Daten-Leben (Charts, Live-Feed), Logo + Splash Screen. **Alle vorhandenen Funktionen bleiben erhalten** (KPIs, Drag & Drop, Agent-Zuweisung, Live-Konsole in Karten, Modals, Token-Budget, SSE). Die Team-Seite bleibt unverändert.
|
||||
|
||||
## Nicht-Ziele (Out of Scope)
|
||||
|
||||
- Multi-Projekt-Architektur (Projekt anlegen/wechseln per Dropdown) → separater v3-Spec. In v2 nur visuell vorbereitet: Projektname im Header mit Chevron (`▾`), ohne Funktion.
|
||||
- Verlaufs-Charts auf der Activity-Page (später).
|
||||
- Kein Frontend-Framework (bewusste Entscheidung: Fastify + String-Templates + Vanilla JS + SSE, kein Build-Step).
|
||||
|
||||
## Architektur-Entscheidung
|
||||
|
||||
`src/server/board.ts` (aktuell ~2100 Zeilen, monolithisch, nutzt `ui-shared.ts` nicht) wird in Module zerlegt, ohne das Serving-Modell zu ändern (Fastify rendert HTML-String beim Start, Daten via `/tasks`, `/agents`, `/budget`, SSE `/events`):
|
||||
|
||||
```
|
||||
src/server/board/
|
||||
index.ts — renderBoardHtml(projectName), orchestriert Module
|
||||
styles.ts — v2 Design-Tokens + Glass-CSS + Animations-Keyframes
|
||||
header.ts — Header (Logo, Projektzelle mit Chevron, Nav, New-Task, SSE-Dot)
|
||||
kpis.ts — KPI-Reihe (Markup + Update-JS)
|
||||
columns.ts — Spalten + Task-Karten + Drag&Drop + Live-Konsole
|
||||
sidebar.ts — Budget-Card (Tabs) + Live-Feed
|
||||
splash.ts — Splash-Screen (Markup + CSS + JS)
|
||||
modals.ts — New-Task / Delete / Task-Detail (unveränderte Logik, neues Styling)
|
||||
assets/
|
||||
logo.svg — AgentHub-Logo (Hexagon-Gradient, Cyan #38bdf8 → Violett #8b5cf6)
|
||||
```
|
||||
|
||||
Design-Tokens werden an `ui-shared.ts` angeglichen (gleiche Farbwerte, Glass-Surface als `--surface` etc.), damit Board und die übrigen Seiten konsistent bleiben. Team-Page wird nicht angefasst.
|
||||
|
||||
## Layout (wie Mockup v2.5)
|
||||
|
||||
Dunkler Gradient-Hintergrund (`radial-gradient`, #1c2547 → #0e1226 → #090c18). Glass-Surfaces: `rgba(255,255,255,.045)`, Border `rgba(255,255,255,.09)`, Radius 12px.
|
||||
|
||||
### Header
|
||||
- Logo (neu, SVG, Hexagon-Gradient), `agenthub` Wortmarke
|
||||
- Projektzelle `/ <projektname> ▾` (nur Optik; Dropdown-Funktion kommt mit v3)
|
||||
- Nav: Board (aktiv) / Team / Activity / Decisions
|
||||
- Rechts: `+ New task` Button (Indigo→Violett-Gradient), SSE-Live-Dot (grün, ping-Animation)
|
||||
|
||||
### KPI-Reihe (4 Karten, nur über dem Board-Bereich, nicht über Sidebar)
|
||||
1. **Open** — Zahl `/ gesamt`, Mini-Area-Chart (Backlog-Verlauf, 14 Tage, aus `/tasks` abgeleitet), Grau
|
||||
2. **In Progress** — Zahl, Agent-Chips: max. 3 sichtbar, dann `+n`-Chip; jeder Chip: Avatar (Agent-Farbe), Live-Punkt (pulsierend), Laufzeit
|
||||
3. **Review** — Zahl, gleiche Chip-Darstellung (Reviewer), max. 3 + `+n`
|
||||
4. **Done** — Zahl `/ gesamt`, Progress-Bar (Anteil erledigt, Grün-Gradient, animierter Fill), Beschreibung („63% · +5 diese Woche") klein rechtsbündig direkt über dem Bar-Ende
|
||||
|
||||
Karten erscheinen gestaffelt (rise-Animation, 70ms Versatz).
|
||||
|
||||
### Board-Spalten (unveränderte Funktion)
|
||||
3 Spalten (Open / In Progress / Review) als Glass-Panels, Header mit Status-Punkt + Count-Pill. Task-Karten: Glass, Hover-Lift, Status-Farbe am linken Rand (dezent), Agent-Tag, Reviewer-Badge, Timer, Glow (`rgba(56,189,248,.35)` Border + Shadow) auf aktiv bearbeiteten Karten. **In-Progress-Karten zeigen einen Progress-Ring** (SVG-Radial, Füllung = verstrichene Zeit / geschätzte Dauer, Mitte = tickende verstrichene Zeit). Schätzung = Median der realen Bearbeitungsdauer der letzten erledigten Tasks (`claimedAt`→`updatedAt`, clientseitig aus `/tasks`); Fallback = Zeit-Kappe analog `MAX_LIVE_ESTIMATE_MIN_PER_TASK` aus `budgetService`. Über der Schätzung kippt der Ring von Cyan nach Amber. (Änderung gegenüber Mockup: ersetzt den dortigen Fantasie-Prozentbalken — ein echter Prozentwert existiert in den Daten nicht.) Drag&Drop (Status) + Agent-Chip-Drop (Zuweisung) + Live-Konsole bleiben funktional identisch.
|
||||
|
||||
### Sidebar rechts
|
||||
**Budget-Card** mit zwei Ebenen, immer genau ein Chart sichtbar:
|
||||
- Über-Tabs: `Token Insights` (Default) | `Verlauf`
|
||||
- Token Insights: Sub-Tabs `Token` | `Kosten`, Session/Total-Segmented-Control und **Reset-Button** wie v1
|
||||
- Token: Half-Donut (animierter Arc) + Per-Agent-Bars (Agent-Farben, animierter Fill)
|
||||
- Kosten: Half-Donut + Firmen-Legende (Anthropic/OpenAI/Moonshot mit Farbpunkten)
|
||||
- Verlauf: Area-Chart (Tokens, 14 Tage) mit Sollwert-Zeile („Ø x/Tag")
|
||||
- Tab-Persistenz in `localStorage`
|
||||
|
||||
**Live-Feed-Card** darunter: letzte ~5 Events aus SSE `/events` (kompakte Zeilen, pulsierender Punkt beim neuesten).
|
||||
|
||||
### Splash Screen
|
||||
Beim Laden von `/board`: Fullscreen-Overlay im Hintergrund-Gradient, zentriertes Logo mit Einblend-/Puls-Animation + „agenthub" Schriftzug; nach DOMContentLoaded + erstem Daten-Fetch (oder max. ~1,2s) smooth ausfaden und Dashboard einblenden. Kein Blockieren der eigentlichen Daten-Requests.
|
||||
|
||||
### Logo
|
||||
`assets/logo.svg`: Hexagon mit Cyan→Violett-Verlauf, ausgestanzter Kern (Knoten-Metapher, passt zum Agenten-Netzwerk). Verwendet in Header, Splash, Favicon.
|
||||
|
||||
## Animationen (dezent, respektiert `prefers-reduced-motion`)
|
||||
- `rise` (opacity + translateY 8px, 0,5–0,6s) für Karten beim ersten Render — nicht bei SSE-/Poll-Updates (wie bisher nur `.card-new`)
|
||||
- `fill` (width 0 → Ziel) für Bars, `draw` (stroke-dashoffset) für Charts/Arcs
|
||||
- `ping` für Live-Dot, `blink` für Status-Punkte
|
||||
- KPI-Wertänderung: kurzer Flash (bestehendes `metric-flash`-Verhalten beibehalten)
|
||||
- Keine Animation bei `prefers-reduced-motion: reduce`
|
||||
|
||||
## Datenfluss
|
||||
Unverändert: initiales `refresh()` (`GET /tasks`, `/agents`, `/budget`), SSE `/events` für Live-Updates, `task-log` SSE für Karten-Konsole, 3s-Polling für Budget. Neu abgeleitet aus vorhandenen Daten:
|
||||
- Backlog-Mini-Area (Open): Open-Count pro Tag der letzten 14 Tage aus Task-`createdAt`/Status-Historie — falls nicht aus den vorhandenen Endpunkten ableitbar, aus `/tasks` (created/status/updated) clientseitig approximiert; kein neuer Endpunkt in v2 (Fallback: flache Linie mit aktuellem Wert).
|
||||
- Verlauf-Area-Chart (Tokens/Tag): aus `/budget`-Historie, falls vorhanden; sonst gleiche Fallback-Strategie. Implementierung klärt, was `/budget` tatsächlich liefert.
|
||||
- „+5 diese Woche" bei Done: aus `/tasks` (done + `updatedAt` in den letzten 7 Tagen).
|
||||
|
||||
## Fehlerbehandlung
|
||||
- SSE-Reconnect-Verhalten wie v1 (Verbindungs-Dot im Header).
|
||||
- Wenn Budget-Endpunkt leer/fehlschlägt: Sidebar-Panels zeigen vorhandene v1-Leerzustände.
|
||||
- Splash hat harte Obergrenze (~1,5s), damit er nie blockiert.
|
||||
|
||||
## Testing
|
||||
- Bestehende Tests (`tests/`) müssen grün bleiben (Board-Rendering ist String-basiert; Snapshot-/String-Assertions ggf. anpassen).
|
||||
- Neue Unit-Tests für: `kpis.ts` (Chip-Kappung 3+`+n`, Prozent-Berechnung Done, „+diese Woche"), `sidebar.ts` (Tab-Default, Reset-Button-Markup), `splash.ts` (Overlay-Markup, reduced-motion Guard), Logo-Einbettung im Header.
|
||||
- Manueller Check: `agenthub server start` → `/board` im Browser (Splash, Tabs, Drag&Drop, Live-Updates).
|
||||
|
||||
## Entscheidungen (Log aus dem Brainstorming)
|
||||
- Design-Richtung: „Glass / Modern SaaS" (Basis B), kein Nuxt/Vue (Overkill, widerspricht Simplicity-Ziel)
|
||||
- Active/Open zu einer Karte „Open" verschmolzen (Kanban-Struktur: Open / In Progress / Review / Done)
|
||||
- Sparklines verworfen zugunsten: Mini-Area (Open), Progress-Bar (Done), Chips (In Progress/Review)
|
||||
- Chips statt Avatar-Stacks, gekappt bei 3 + `+n`
|
||||
- Verschachtelte Tabs rechts (Token Insights default), Reset-Button wie v1
|
||||
- Verlaufs-Charts später ggf. auf Activity-Page
|
||||
- Multi-Projekt = v3 (eigener Spec)
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
114
src/cli/commands/ask.ts
Normal file
114
src/cli/commands/ask.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { createAsk, listAsks, answerAsk, escalateAsk } from '../../core/services/askService.js';
|
||||
import { parseSSEBuffer } from './watch.js';
|
||||
import { remoteClient } from '../remoteClient.js';
|
||||
import type { Ask } from '../../core/schema.js';
|
||||
|
||||
export function askCreate(cwd: string, opts: { from: string; question: string; taskId?: string }): Ask {
|
||||
const a = createAsk(cwd, { from: opts.from, question: opts.question, taskId: opts.taskId });
|
||||
console.log(`AgentHub: Ask sent ${a.id} (${a.from} → ${a.to})${a.taskId ? ` [${a.taskId}]` : ''}: ${a.question}`);
|
||||
return a;
|
||||
}
|
||||
|
||||
export function askList(cwd: string, opts: { pending?: boolean } = {}): void {
|
||||
const asks = listAsks(cwd, opts.pending ? { status: 'pending' } : {});
|
||||
if (asks.length === 0) {
|
||||
console.log('No asks.');
|
||||
return;
|
||||
}
|
||||
for (const a of asks) {
|
||||
const flag = a.status === 'pending' ? '●' : ' ';
|
||||
let line = `${flag} ${a.id} ${a.from} → ${a.to} [${a.status}]${a.taskId ? ` (${a.taskId})` : ''}: ${a.question}`;
|
||||
if (a.answer) line += `\n ↳ ${a.answeredBy ?? a.to}: ${a.answer}`;
|
||||
if (a.status === 'escalated') line += `\n ↳ escalated to ${a.escalatedTo ?? 'ceo'}`;
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function askAnswer(cwd: string, id: string, text: string, by?: string): void {
|
||||
const a = answerAsk(cwd, id, text, by);
|
||||
console.log(`AgentHub: Ask answered ${a.id}${by ? ` by ${by}` : ''}`);
|
||||
}
|
||||
|
||||
export function askEscalate(cwd: string, id: string, note?: string): void {
|
||||
const a = escalateAsk(cwd, id, note);
|
||||
console.log(`AgentHub: Ask escalated ${a.id} → ${a.escalatedTo ?? 'ceo'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block on the SSE stream until the Ask leaves `pending` (answered/escalated),
|
||||
* or the timeout elapses. Mirrors the work-loop wait (reconnect-friendly) but
|
||||
* wakes on `ask` events. Resolves with the settled Ask, or null on timeout.
|
||||
*/
|
||||
export function waitForAsk(serverUrl: string, askId: string, timeoutSec?: number): Promise<Ask | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let controller: AbortController | undefined;
|
||||
const finish = (v: Ask | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
controller?.abort();
|
||||
} catch {
|
||||
/* already aborted */
|
||||
}
|
||||
resolve(v);
|
||||
};
|
||||
const deadline = timeoutSec ? Date.now() + timeoutSec * 1000 : undefined;
|
||||
const timer = timeoutSec ? setTimeout(() => finish(null), timeoutSec * 1000) : undefined;
|
||||
|
||||
const check = async (): Promise<boolean> => {
|
||||
try {
|
||||
const { ask } = await remoteClient.getAsk(serverUrl, askId);
|
||||
if (ask.status !== 'pending') {
|
||||
if (timer) clearTimeout(timer);
|
||||
finish(ask);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* transient — keep listening */
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const loop = async () => {
|
||||
while (!settled && (deadline === undefined || Date.now() < deadline)) {
|
||||
controller = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
|
||||
if (!res.body) throw new Error('SSE response has no body');
|
||||
if (await check()) return; // close the gap after subscribing
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (!settled) {
|
||||
let done: boolean;
|
||||
let value: Uint8Array | undefined;
|
||||
try {
|
||||
({ done, value } = await reader.read());
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (done) break;
|
||||
if (value) buffer += decoder.decode(value, { stream: true });
|
||||
const { events, remaining } = parseSSEBuffer(buffer);
|
||||
buffer = remaining;
|
||||
if (events.some((e) => e.type === 'ask')) {
|
||||
if (await check()) return;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
|
||||
}
|
||||
if (settled) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
finish(null);
|
||||
};
|
||||
|
||||
loop().catch(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
finish(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { createMessage, listInbox } from '../../core/services/messageService.js';
|
||||
import { createMessage, listInbox, markMessageRead, ackMessage, getMessage } from '../../core/services/messageService.js';
|
||||
import type { Message } from '../../core/schema.js';
|
||||
|
||||
export function messageSend(
|
||||
cwd: string,
|
||||
@ -19,3 +20,41 @@ export function inboxList(cwd: string, opts: { agent: string; unreadOnly?: boole
|
||||
console.log(`${flag} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function messageRead(cwd: string, id: string): void {
|
||||
const m = markMessageRead(cwd, id);
|
||||
console.log(`AgentHub: Message read ${m.id} (${m.from} → ${m.to})`);
|
||||
}
|
||||
|
||||
export function inboxMarkRead(cwd: string, opts: { agent: string; unreadOnly?: boolean }): void {
|
||||
const msgs = listInbox(cwd, opts.agent, { unreadOnly: opts.unreadOnly });
|
||||
for (const m of msgs) markMessageRead(cwd, m.id);
|
||||
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${opts.agent}`);
|
||||
}
|
||||
|
||||
export function messageAck(cwd: string, id: string, by?: string): void {
|
||||
const m = ackMessage(cwd, id, by);
|
||||
console.log(`AgentHub: Message acked ${m.id} (${m.from} → ${m.to})${by ? ` by ${by}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a message: loads the parent, sends a new message back to the parent's
|
||||
* sender (to = parent.from), links it via replyTo, and inherits the parent's
|
||||
* taskId unless one is given.
|
||||
*/
|
||||
export function messageReply(
|
||||
cwd: string,
|
||||
parentId: string,
|
||||
opts: { from: string; text: string; taskId?: string },
|
||||
): Message {
|
||||
const { message: parent } = getMessage(cwd, parentId);
|
||||
const m = createMessage(cwd, {
|
||||
from: opts.from,
|
||||
to: parent.from,
|
||||
text: opts.text,
|
||||
taskId: opts.taskId ?? parent.taskId,
|
||||
replyTo: parentId,
|
||||
});
|
||||
console.log(`AgentHub: Reply sent ${m.id} (${m.from} → ${m.to}) ↩ ${parentId}`);
|
||||
return m;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
||||
import { appendTaskLog } from '../../core/services/taskLogService.js';
|
||||
import type { Task } from '../../core/schema.js';
|
||||
|
||||
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
||||
@ -69,3 +70,12 @@ export function taskAssign(cwd: string, id: string, agentName: string): void {
|
||||
assignTask(cwd, id, agentName);
|
||||
console.log(`AgentHub: Task assigned ${id} → ${agentName}`);
|
||||
}
|
||||
|
||||
export function taskLog(
|
||||
cwd: string,
|
||||
id: string,
|
||||
entry: { text: string; agent?: string; level?: string },
|
||||
): void {
|
||||
const rec = appendTaskLog(cwd, id, entry);
|
||||
console.log(`AgentHub: logged ${id} ${rec.text}`);
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentHubEvent } from '../../server/events.js';
|
||||
import { messageRecipientAliases } from '../../core/services/messageService.js';
|
||||
|
||||
// Re-export so tests can import type + helpers from one place.
|
||||
export type { AgentHubEvent } from '../../server/events.js';
|
||||
@ -134,6 +135,36 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch unread messages currently addressed to an agent or its role alias. */
|
||||
async function fetchUnreadMessages(serverUrl: string, agent: string): Promise<AgentHubEvent[]> {
|
||||
try {
|
||||
const res = await fetch(new URL(`/messages?agent=${encodeURIComponent(agent)}&unread=1`, serverUrl).toString());
|
||||
if (!res.ok) return [];
|
||||
const messages = (await res.json()) as Array<{
|
||||
id: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
return messages.map((m) => ({
|
||||
type: 'message',
|
||||
action: 'created',
|
||||
id: m.id,
|
||||
title: `${m.from ?? ''} → ${m.to ?? ''}`,
|
||||
status: m.status ?? 'unread',
|
||||
assignedTo: m.to,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageFor(event: AgentHubEvent, agent: string): boolean {
|
||||
if (event.type !== 'message' || event.action !== 'created') return false;
|
||||
const to = event.assignedTo;
|
||||
return !!to && messageRecipientAliases(agent).has(String(to).toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the AgentHub server's SSE endpoint and stream events to stdout.
|
||||
*
|
||||
@ -148,7 +179,7 @@ async function fetchReviewTasks(serverUrl: string): Promise<AgentHubEvent[]> {
|
||||
*/
|
||||
export async function watchEvents(
|
||||
serverUrl: string,
|
||||
options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {},
|
||||
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const url = new URL('/events', serverUrl);
|
||||
// Pass role to the server for an additional server-side filter (saves
|
||||
@ -192,6 +223,14 @@ export async function watchEvents(
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (options.awaitMessage && !options.newOnly) {
|
||||
const pending = await fetchUnreadMessages(serverUrl, options.awaitMessage);
|
||||
if (pending.length > 0) {
|
||||
for (const ev of pending) console.log(formatEvent(ev));
|
||||
await reader.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
let done: boolean;
|
||||
@ -230,6 +269,10 @@ export async function watchEvents(
|
||||
await reader.cancel();
|
||||
return;
|
||||
}
|
||||
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
|
||||
await reader.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { parseSSEBuffer } from './watch.js';
|
||||
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js';
|
||||
import { discoverServer as discoverHubServer } from '../../discovery.js';
|
||||
import { remoteClient } from '../remoteClient.js';
|
||||
|
||||
/**
|
||||
* `agenthub work --agent <name> --role <role>` — the auto-claim primitive
|
||||
@ -13,7 +15,47 @@ import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentCont
|
||||
* up automatically without a human prompt. Best run in the background so the
|
||||
* wait doesn't tie up the foreground.
|
||||
*/
|
||||
export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
|
||||
interface WorkAgentContext extends AgentContext {
|
||||
timeoutSec?: number;
|
||||
discoverServer?: (timeoutMs?: number) => Promise<string | undefined>;
|
||||
reconnectBackoffMs?: number[];
|
||||
/**
|
||||
* Unattended mode (TSK-0118): the agent runs without a human at the keyboard.
|
||||
* It must never pause for human input — when it needs a decision it routes an
|
||||
* `agenthub ask` to the architect and awaits the answer, instead of stalling.
|
||||
*/
|
||||
unattended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + print + mark-read the agent's unread messages. Returns how many were
|
||||
* surfaced. This is what lets the work loop wake on an architect follow-up /
|
||||
* question (TSK-0119): after a `task review` submit the implementer re-arms
|
||||
* `work` and stays reachable — a reopen or a new assignment wakes it via a task
|
||||
* event, and a plain message wakes it here instead of leaving it dormant.
|
||||
*/
|
||||
async function drainAgentMessages(ctx: WorkAgentContext): Promise<number> {
|
||||
if (!ctx.serverUrl) return 0;
|
||||
let msgs;
|
||||
try {
|
||||
msgs = await remoteClient.getInbox(ctx.serverUrl, ctx.agent, true);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
if (!msgs.length) return 0;
|
||||
console.log(`AgentHub: ${msgs.length} message${msgs.length === 1 ? '' : 's'} for ${ctx.agent}:`);
|
||||
for (const m of msgs) {
|
||||
console.log(` ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
||||
try {
|
||||
await remoteClient.markMessageRead(ctx.serverUrl, m.id);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
return msgs.length;
|
||||
}
|
||||
|
||||
export async function workAgent(ctx: WorkAgentContext): Promise<void> {
|
||||
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
|
||||
|
||||
// Already-waiting task?
|
||||
@ -28,22 +70,43 @@ export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Pr
|
||||
return;
|
||||
}
|
||||
|
||||
// A message may already be waiting (architect followed up while we implemented
|
||||
// + submitted). Surface it now instead of blocking past a pending question.
|
||||
if (await drainAgentMessages(ctx)) return;
|
||||
|
||||
console.log(
|
||||
`AgentHub: waiting for a task addressed to ${ctx.agent}…${ctx.timeoutSec ? ` (timeout ${ctx.timeoutSec}s)` : ''}`,
|
||||
`AgentHub: waiting for a task or message addressed to ${ctx.agent}…${ctx.timeoutSec ? ` (timeout ${ctx.timeoutSec}s)` : ''}`,
|
||||
);
|
||||
if (ctx.unattended) {
|
||||
console.log('AgentHub: unattended mode — never pause for human input; route decisions via `agenthub ask` to the architect.');
|
||||
}
|
||||
await waitAndClaim(ctx);
|
||||
}
|
||||
|
||||
function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
|
||||
const serverUrl = ctx.serverUrl as string;
|
||||
function remainingMs(deadline: number | undefined): number {
|
||||
return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now());
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function resolveReconnectUrl(ctx: WorkAgentContext, currentUrl: string): Promise<string> {
|
||||
if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER;
|
||||
const discovered = await (ctx.discoverServer ?? discoverHubServer)(2000);
|
||||
return discovered || currentUrl;
|
||||
}
|
||||
|
||||
function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
||||
let serverUrl = ctx.serverUrl as string;
|
||||
return new Promise((resolve) => {
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
let controller: AbortController | undefined;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
controller.abort();
|
||||
controller?.abort();
|
||||
} catch {
|
||||
/* already aborted */
|
||||
}
|
||||
@ -56,10 +119,14 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
|
||||
finish();
|
||||
}, ctx.timeoutSec * 1000)
|
||||
: undefined;
|
||||
const deadline = ctx.timeoutSec ? Date.now() + ctx.timeoutSec * 1000 : undefined;
|
||||
const backoffs = ctx.reconnectBackoffMs ?? [2000, 5000, 10000];
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
// Re-query then claim if a task addressed to us is now open. Returns true
|
||||
// if a task was claimed (so the caller can stop).
|
||||
const tryClaim = async (): Promise<boolean> => {
|
||||
ctx.serverUrl = serverUrl;
|
||||
const f = await findAddressedOpenTask(ctx);
|
||||
if (!f) return false;
|
||||
if (timer) clearTimeout(timer);
|
||||
@ -68,47 +135,80 @@ function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void
|
||||
return true;
|
||||
};
|
||||
|
||||
fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
|
||||
.then(async (res) => {
|
||||
if (!res.body) {
|
||||
if (timer) clearTimeout(timer);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
// Close the gap: a task may have appeared between the initial check and
|
||||
// this subscription — check once more now that we're listening.
|
||||
if (await tryClaim()) return;
|
||||
// Wake on an architect follow-up message (not just tasks): surface it and
|
||||
// stop, so the implementer never sits dormant on a pending question.
|
||||
const trySurfaceMessages = async (): Promise<boolean> => {
|
||||
ctx.serverUrl = serverUrl;
|
||||
const n = await drainAgentMessages(ctx);
|
||||
if (n === 0) return false;
|
||||
if (timer) clearTimeout(timer);
|
||||
finish();
|
||||
return true;
|
||||
};
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (!settled) {
|
||||
let done: boolean;
|
||||
let value: Uint8Array | undefined;
|
||||
try {
|
||||
({ done, value } = await reader.read());
|
||||
} catch {
|
||||
break; // aborted or connection closed
|
||||
}
|
||||
if (done) break;
|
||||
if (value) buffer += decoder.decode(value, { stream: true });
|
||||
const waitLoop = async () => {
|
||||
while (!settled && remainingMs(deadline) > 0) {
|
||||
controller = new AbortController();
|
||||
try {
|
||||
ctx.serverUrl = serverUrl;
|
||||
const res = await fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } });
|
||||
if (!res.body) throw new Error('SSE response has no body');
|
||||
|
||||
const { events, remaining } = parseSSEBuffer(buffer);
|
||||
buffer = remaining;
|
||||
// Any task event may mean a task addressed to us just opened/reopened.
|
||||
if (events.some((e) => e.type === 'task')) {
|
||||
if (await tryClaim()) return;
|
||||
// Close the gap: a task or message may have appeared between the initial
|
||||
// check and this subscription — check once more now that we're listening.
|
||||
if (await tryClaim()) return;
|
||||
if (await trySurfaceMessages()) return;
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
reconnectAttempt = 0;
|
||||
while (!settled) {
|
||||
let done: boolean;
|
||||
let value: Uint8Array | undefined;
|
||||
try {
|
||||
({ done, value } = await reader.read());
|
||||
} catch {
|
||||
break; // aborted or connection closed
|
||||
}
|
||||
if (done) break;
|
||||
if (value) buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const { events, remaining } = parseSSEBuffer(buffer);
|
||||
buffer = remaining;
|
||||
// Any task event may mean a task addressed to us just opened/reopened.
|
||||
if (events.some((e) => e.type === 'task')) {
|
||||
if (await tryClaim()) return;
|
||||
}
|
||||
// A message event may be an architect follow-up/question for us.
|
||||
if (events.some((e) => e.type === 'message')) {
|
||||
if (await trySurfaceMessages()) return;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
finish();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!(err instanceof Error && err.name === 'AbortError')) {
|
||||
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
if (settled || remainingMs(deadline) <= 0) break;
|
||||
serverUrl = await resolveReconnectUrl(ctx, serverUrl);
|
||||
ctx.serverUrl = serverUrl;
|
||||
const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000;
|
||||
reconnectAttempt += 1;
|
||||
const delay = deadline === undefined ? backoff : Math.min(backoff, remainingMs(deadline));
|
||||
if (delay > 0) await sleep(delay);
|
||||
}
|
||||
if (!settled) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
console.log(`AgentHub: no task for ${ctx.agent} after ${ctx.timeoutSec}s — exiting.`);
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
finish();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
waitLoop().catch((err: unknown) => {
|
||||
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
if (timer) clearTimeout(timer);
|
||||
finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
219
src/cli/index.ts
219
src/cli/index.ts
@ -2,10 +2,11 @@ import { Command } from 'commander';
|
||||
import { init } from './commands/init.js';
|
||||
import { status } from './commands/status.js';
|
||||
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
||||
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
|
||||
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
|
||||
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||
import { messageSend, inboxList } from './commands/message.js';
|
||||
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
||||
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
|
||||
import { agentSetup, hookContext } from './commands/agentSetup.js';
|
||||
import { syncOrgFromFile } from '../core/services/orgService.js';
|
||||
import { delegate } from './commands/delegate.js';
|
||||
@ -365,6 +366,23 @@ export function createProgram(cwd: string): Command {
|
||||
taskReopen(projectCwd, id);
|
||||
}
|
||||
});
|
||||
taskCmd
|
||||
.command('log <id>')
|
||||
.description('Append a progress line to a task\'s live console (streams to open task-detail pages)')
|
||||
.requiredOption('--text <text>', 'Progress line')
|
||||
.option('--agent <agent>', 'Reporting agent')
|
||||
.option('--level <level>', 'Log level (info | status | warn | error)')
|
||||
.action(async (id, options: { text: string; agent?: string; level?: string }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
await remoteClient.appendTaskLog(serverUrl, id, { text: options.text, agent: options.agent, level: options.level });
|
||||
console.log(`AgentHub: logged ${id} ${options.text}`);
|
||||
});
|
||||
} else {
|
||||
taskLog(projectCwd, id, { text: options.text, agent: options.agent, level: options.level });
|
||||
}
|
||||
});
|
||||
program.addCommand(taskCmd);
|
||||
|
||||
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
||||
@ -457,8 +475,83 @@ export function createProgram(cwd: string): Command {
|
||||
program.addCommand(decisionCmd);
|
||||
|
||||
// ─── messaging ───────────────────────────────────────────────────────────
|
||||
program
|
||||
.command('message <to> <text>')
|
||||
const messageCmd = new Command('message')
|
||||
.description('Send and manage direct messages')
|
||||
.argument('[to]', 'Recipient agent/role')
|
||||
.argument('[text]', 'Message text')
|
||||
.option('--from <agent>', 'Sender agent name')
|
||||
.option('--task <id>', 'Related task ID')
|
||||
.action(async (to: string | undefined, text: string | undefined, options: { from?: string; task?: string }) => {
|
||||
if (!to || !text || !options.from) {
|
||||
messageCmd.help({ error: true });
|
||||
return;
|
||||
}
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
const payload = { from: options.from, to, text, taskId: options.task };
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const m = await remoteClient.sendMessage(serverUrl, payload);
|
||||
console.log(`AgentHub: Message sent ${m.id} (${m.from} → ${m.to})`);
|
||||
});
|
||||
} else {
|
||||
messageSend(projectCwd, payload);
|
||||
}
|
||||
});
|
||||
messageCmd
|
||||
.command('read <id>')
|
||||
.description('Mark a message as read')
|
||||
.action(async (id: string) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const m = await remoteClient.markMessageRead(serverUrl, id);
|
||||
console.log(`AgentHub: Message read ${m.id} (${m.from} → ${m.to})`);
|
||||
});
|
||||
} else {
|
||||
messageRead(projectCwd, id);
|
||||
}
|
||||
});
|
||||
messageCmd
|
||||
.command('ack <id>')
|
||||
.description('Acknowledge a message (strongest read-receipt: you actioned it)')
|
||||
.option('--by <agent>', 'Agent acknowledging the message')
|
||||
.action(async (id: string, options: { by?: string }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const m = await remoteClient.ackMessage(serverUrl, id, options.by);
|
||||
console.log(`AgentHub: Message acked ${m.id} (${m.from} → ${m.to})${options.by ? ` by ${options.by}` : ''}`);
|
||||
});
|
||||
} else {
|
||||
messageAck(projectCwd, id, options.by);
|
||||
}
|
||||
});
|
||||
messageCmd
|
||||
.command('reply <parentId>')
|
||||
.description('Reply to a message: sends back to its sender, links via replyTo, inherits its task')
|
||||
.requiredOption('--from <agent>', 'Sender agent name')
|
||||
.requiredOption('--text <text>', 'Reply text')
|
||||
.option('--task <id>', 'Related task ID (defaults to the parent message\'s task)')
|
||||
.action(async (parentId: string, options: { from: string; text: string; task?: string }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const { message: parent } = await remoteClient.getMessage(serverUrl, parentId);
|
||||
const m = await remoteClient.sendMessage(serverUrl, {
|
||||
from: options.from,
|
||||
to: parent.from,
|
||||
text: options.text,
|
||||
taskId: options.task ?? parent.taskId,
|
||||
replyTo: parentId,
|
||||
});
|
||||
console.log(`AgentHub: Reply sent ${m.id} (${m.from} → ${m.to}) ↩ ${parentId}`);
|
||||
});
|
||||
} else {
|
||||
messageReply(projectCwd, parentId, { from: options.from, text: options.text, taskId: options.task });
|
||||
}
|
||||
});
|
||||
messageCmd
|
||||
.command('send <to> <text>')
|
||||
.description('Send a direct message to another agent')
|
||||
.requiredOption('--from <agent>', 'Sender agent name')
|
||||
.option('--task <id>', 'Related task ID')
|
||||
@ -474,14 +567,26 @@ export function createProgram(cwd: string): Command {
|
||||
messageSend(projectCwd, payload);
|
||||
}
|
||||
});
|
||||
program.addCommand(messageCmd);
|
||||
|
||||
program
|
||||
.command('inbox')
|
||||
.description('Read messages addressed to an agent')
|
||||
.requiredOption('--agent <agent>', 'Agent whose inbox to read')
|
||||
.option('--unread', 'Only unread messages')
|
||||
.action(async (options: { agent: string; unread?: boolean }) => {
|
||||
.option('--mark-read', 'Mark listed messages as read')
|
||||
.option('--wait', 'Wait until a new unread message arrives for this agent')
|
||||
.action(async (options: { agent: string; unread?: boolean; markRead?: boolean; wait?: boolean }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (options.wait) {
|
||||
if (!serverUrl) {
|
||||
console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
await watchEvents(serverUrl, { awaitMessage: options.agent, newOnly: true });
|
||||
return;
|
||||
}
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
|
||||
@ -489,12 +594,107 @@ export function createProgram(cwd: string): Command {
|
||||
for (const m of msgs) {
|
||||
console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
||||
}
|
||||
if (options.markRead) {
|
||||
for (const m of msgs) await remoteClient.markMessageRead(serverUrl, m.id);
|
||||
console.log(`AgentHub: marked ${msgs.length} message${msgs.length === 1 ? '' : 's'} read for ${options.agent}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
|
||||
if (options.markRead) inboxMarkRead(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
|
||||
else inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── asks (autonomous decision-routing) ────────────────────────────────────
|
||||
const askCmd = new Command('ask')
|
||||
.description('Ask the architect a blocking question (routes to the architect, never the CEO)')
|
||||
.argument('[question]', 'Question to route to the architect')
|
||||
.option('--from <agent>', 'Asking agent')
|
||||
.option('--task <id>', 'Related task ID')
|
||||
.option('--wait', 'Block until the architect answers or escalates')
|
||||
.option('--timeout <sec>', 'With --wait: stop waiting after N seconds')
|
||||
.action(async (question: string | undefined, options: { from?: string; task?: string; wait?: boolean; timeout?: string }) => {
|
||||
if (!question || !options.from) {
|
||||
askCmd.help({ error: true });
|
||||
return;
|
||||
}
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
const payload = { from: options.from, question, taskId: options.task };
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const a = await remoteClient.createAsk(serverUrl, payload);
|
||||
console.log(`AgentHub: Ask sent ${a.id} (${a.from} → ${a.to})${a.taskId ? ` [${a.taskId}]` : ''}: ${a.question}`);
|
||||
if (options.wait) {
|
||||
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
|
||||
console.log(`AgentHub: waiting for an answer to ${a.id}…${timeoutSec ? ` (timeout ${timeoutSec}s)` : ''}`);
|
||||
const answered = await waitForAsk(serverUrl, a.id, timeoutSec);
|
||||
if (!answered) {
|
||||
console.log(`AgentHub: no answer to ${a.id}${timeoutSec ? ` within ${timeoutSec}s` : ''} — re-check with: agenthub ask list`);
|
||||
return;
|
||||
}
|
||||
if (answered.status === 'answered') {
|
||||
console.log(`AgentHub: ${a.id} answered by ${answered.answeredBy ?? answered.to}: ${answered.answer}`);
|
||||
} else {
|
||||
console.log(`AgentHub: ${a.id} escalated to ${answered.escalatedTo ?? 'ceo'} — await the CEO decision.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
askCreate(projectCwd, payload);
|
||||
if (options.wait) console.log('AgentHub: --wait needs a running server; the ask was created without waiting.');
|
||||
}
|
||||
});
|
||||
askCmd
|
||||
.command('list')
|
||||
.description('List asks (● = pending)')
|
||||
.option('--pending', 'Only pending asks')
|
||||
.action(async (options: { pending?: boolean }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const asks = await remoteClient.listAsks(serverUrl, options.pending ? { status: 'pending' } : undefined);
|
||||
if (asks.length === 0) { console.log('No asks.'); return; }
|
||||
for (const a of asks) {
|
||||
console.log(`${a.status === 'pending' ? '●' : ' '} ${a.id} ${a.from} → ${a.to} [${a.status}]${a.taskId ? ` (${a.taskId})` : ''}: ${a.question}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
askList(projectCwd, { pending: options.pending });
|
||||
}
|
||||
});
|
||||
askCmd
|
||||
.command('answer <id>')
|
||||
.description('Answer an ask (architect)')
|
||||
.requiredOption('--text <text>', 'Answer text')
|
||||
.option('--by <agent>', 'Answering agent')
|
||||
.action(async (id: string, options: { text: string; by?: string }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const a = await remoteClient.answerAsk(serverUrl, id, options.text, options.by);
|
||||
console.log(`AgentHub: Ask answered ${a.id}${options.by ? ` by ${options.by}` : ''}`);
|
||||
});
|
||||
} else {
|
||||
askAnswer(projectCwd, id, options.text, options.by);
|
||||
}
|
||||
});
|
||||
askCmd
|
||||
.command('escalate <id>')
|
||||
.description('Escalate an ask to the CEO (architect: release/publish/push, OSS, architecture pivots)')
|
||||
.option('--note <note>', 'Escalation note')
|
||||
.action(async (id: string, options: { note?: string }) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const a = await remoteClient.escalateAsk(serverUrl, id, options.note);
|
||||
console.log(`AgentHub: Ask escalated ${a.id} → ${a.escalatedTo ?? 'ceo'}`);
|
||||
});
|
||||
} else {
|
||||
askEscalate(projectCwd, id, options.note);
|
||||
}
|
||||
});
|
||||
program.addCommand(askCmd);
|
||||
|
||||
// ─── auto-start ──────────────────────────────────────────────────────────
|
||||
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
||||
agentCmd
|
||||
@ -621,10 +821,11 @@ export function createProgram(cwd: string): Command {
|
||||
.requiredOption('--agent <name>', 'Agent name')
|
||||
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
||||
.option('--timeout <sec>', 'Stop waiting after N seconds (default: wait indefinitely)')
|
||||
.option('--unattended', 'Unattended mode: never pause for human input — route decisions via `agenthub ask`')
|
||||
.action(async (options) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
|
||||
const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec };
|
||||
const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec, unattended: !!options.unattended };
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, () => workAgent(ctx));
|
||||
} else {
|
||||
@ -639,7 +840,8 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)')
|
||||
.option('--role <role>', 'Client-side role filter (only show events for this role)')
|
||||
.option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
|
||||
.option('--new-only', 'With --await-review: fire only on NEW submissions, ignore tasks already in review on connect (re-armable without spinning)')
|
||||
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
|
||||
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
|
||||
.action(async (options) => {
|
||||
const { serverUrl } = await resolveContext(program, cwd);
|
||||
if (!serverUrl) {
|
||||
@ -651,6 +853,7 @@ export function createProgram(cwd: string): Command {
|
||||
once: options.once as boolean | undefined,
|
||||
role: options.role as string | undefined,
|
||||
awaitReview: options.awaitReview as boolean | undefined,
|
||||
awaitMessage: options.awaitMessage as string | undefined,
|
||||
newOnly: options.newOnly as boolean | undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
|
||||
import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from '../core/schema.js';
|
||||
import type { IndexEntry } from '../core/index.js';
|
||||
import type { InboxMessage } from '../core/services/messageService.js';
|
||||
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
||||
|
||||
export class RemoteError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
@ -92,6 +93,14 @@ export const remoteClient = {
|
||||
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
||||
},
|
||||
|
||||
async appendTaskLog(
|
||||
baseUrl: string,
|
||||
id: string,
|
||||
entry: { text: string; agent?: string; level?: string },
|
||||
): Promise<TaskLogEntry> {
|
||||
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
|
||||
},
|
||||
|
||||
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
||||
},
|
||||
@ -137,6 +146,36 @@ export const remoteClient = {
|
||||
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
|
||||
},
|
||||
|
||||
async ackMessage(baseUrl: string, id: string, by?: string): Promise<Message> {
|
||||
return request<Message>(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined);
|
||||
},
|
||||
|
||||
async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> {
|
||||
return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`);
|
||||
},
|
||||
|
||||
async createAsk(baseUrl: string, options: Partial<Ask>): Promise<Ask> {
|
||||
return request<Ask>(baseUrl, 'POST', '/asks', options);
|
||||
},
|
||||
|
||||
async listAsks(baseUrl: string, filters?: { to?: string; status?: string }): Promise<Ask[]> {
|
||||
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
||||
const qs = params.toString();
|
||||
return request<Ask[]>(baseUrl, 'GET', `/asks${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
|
||||
async getAsk(baseUrl: string, id: string): Promise<{ ask: Ask; body: string }> {
|
||||
return request<{ ask: Ask; body: string }>(baseUrl, 'GET', `/asks/${id}`);
|
||||
},
|
||||
|
||||
async answerAsk(baseUrl: string, id: string, text: string, by?: string): Promise<Ask> {
|
||||
return request<Ask>(baseUrl, 'POST', `/asks/${id}/answer`, { text, by });
|
||||
},
|
||||
|
||||
async escalateAsk(baseUrl: string, id: string, note?: string): Promise<Ask> {
|
||||
return request<Ask>(baseUrl, 'POST', `/asks/${id}/escalate`, note ? { note } : undefined);
|
||||
},
|
||||
|
||||
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
|
||||
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
|
||||
},
|
||||
|
||||
@ -7,6 +7,7 @@ const prefixes: Record<string, string> = {
|
||||
decision: 'DEC',
|
||||
memory: 'MEM',
|
||||
message: 'MSG',
|
||||
ask: 'ASK',
|
||||
};
|
||||
|
||||
export type CounterType = keyof typeof prefixes;
|
||||
|
||||
@ -14,6 +14,7 @@ export interface IndexEntry {
|
||||
status?: string;
|
||||
role?: string;
|
||||
assignedTo?: string;
|
||||
claimedBy?: string;
|
||||
reviewer?: string;
|
||||
tags?: string;
|
||||
// Handoff-specific routing fields
|
||||
@ -46,6 +47,7 @@ export class Index {
|
||||
status TEXT,
|
||||
role TEXT,
|
||||
assignedTo TEXT,
|
||||
claimedBy TEXT,
|
||||
reviewer TEXT,
|
||||
tags TEXT,
|
||||
fromRole TEXT,
|
||||
@ -62,18 +64,26 @@ export class Index {
|
||||
const existingCols = new Set(
|
||||
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
|
||||
for (const col of ['claimedBy', 'reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
|
||||
if (!existingCols.has(col)) {
|
||||
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upsert(entry: IndexEntry): void {
|
||||
/**
|
||||
* Upsert an entity into the index. Pass `{ fts: false }` to keep it out of the
|
||||
* full-text search table (e.g. Asks — transient decision-routing questions
|
||||
* that shouldn't pollute `memory search`); it still lives in the `entities`
|
||||
* table so it can be listed/filtered by type.
|
||||
*/
|
||||
upsert(entry: IndexEntry, opts: { fts?: boolean } = {}): void {
|
||||
const { fts = true } = opts;
|
||||
const params = {
|
||||
status: null,
|
||||
role: null,
|
||||
assignedTo: null,
|
||||
claimedBy: null,
|
||||
reviewer: null,
|
||||
tags: null,
|
||||
fromRole: null,
|
||||
@ -86,21 +96,25 @@ export class Index {
|
||||
};
|
||||
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
|
||||
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
|
||||
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, claimedBy, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
|
||||
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @claimedBy, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type=@type, title=@title, content=@content, filePath=@filePath,
|
||||
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
|
||||
assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags,
|
||||
assignedTo=@assignedTo, claimedBy=@claimedBy, reviewer=@reviewer, tags=@tags,
|
||||
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
|
||||
taskId=@taskId, relatedTasks=@relatedTasks
|
||||
`);
|
||||
insert.run(params);
|
||||
|
||||
const search = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO search (id, title, content) VALUES (@id, @title, @content)
|
||||
`);
|
||||
search.run(params);
|
||||
if (fts) {
|
||||
// The FTS5 `search` table has no UNIQUE key on `id` (it's a plain indexed
|
||||
// column), so `INSERT OR REPLACE` would NOT replace — it would append a new
|
||||
// row on every update, and the search JOIN would then return the same id
|
||||
// multiple times. Delete-then-insert keeps exactly one FTS row per id.
|
||||
this.db.prepare('DELETE FROM search WHERE id = @id').run({ id: params.id });
|
||||
this.db.prepare('INSERT INTO search (id, title, content) VALUES (@id, @title, @content)').run(params);
|
||||
}
|
||||
}
|
||||
|
||||
/** Permanently drop an entity from both the entities table and the FTS index. */
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join, parse } from 'path';
|
||||
|
||||
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'status';
|
||||
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'asks' | 'status';
|
||||
|
||||
export function getAgentHubDir(cwd: string = process.cwd()): string {
|
||||
return join(cwd, '.agenthub');
|
||||
|
||||
@ -15,6 +15,7 @@ export const TaskSchema = z.object({
|
||||
priority: Priority.default('medium'),
|
||||
role: Role.optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
claimedBy: z.string().optional(),
|
||||
reviewer: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
@ -83,7 +84,40 @@ export const MessageSchema = z.object({
|
||||
to: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
taskId: z.string().optional(),
|
||||
status: z.enum(['unread', 'read']).default('unread'),
|
||||
/**
|
||||
* Read-receipt lifecycle:
|
||||
* unread — created, never fetched by the recipient
|
||||
* delivered — surfaced to the recipient's inbox (fetched), not yet opened
|
||||
* read — recipient marked it read
|
||||
* acked — recipient explicitly acknowledged/actioned it
|
||||
* Additive + backward-compatible: old records ('unread'|'read') stay valid.
|
||||
*/
|
||||
status: z.enum(['unread', 'delivered', 'read', 'acked']).default('unread'),
|
||||
/** For threaded replies: the id of the parent message this answers. */
|
||||
replyTo: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const AskStatus = z.enum(['pending', 'answered', 'escalated']);
|
||||
|
||||
/**
|
||||
* A blocking question routed for a decision (TSK-0118). Unlike a message (a
|
||||
* fire-and-forget ping), an Ask has a lifecycle: an implementer asks, the
|
||||
* architect answers (closing it) or escalates it to the CEO. It is the single
|
||||
* blocking channel — an implementer awaits exactly ONE Ask, never two.
|
||||
*/
|
||||
export const AskSchema = z.object({
|
||||
id: z.string().regex(/^ASK-\d{4}$/),
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
question: z.string().min(1),
|
||||
taskId: z.string().optional(),
|
||||
status: AskStatus.default('pending'),
|
||||
answer: z.string().optional(),
|
||||
answeredBy: z.string().optional(),
|
||||
escalatedTo: z.string().optional(),
|
||||
decisionId: z.string().optional(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
@ -170,5 +204,6 @@ export type Handoff = z.infer<typeof HandoffSchema>;
|
||||
export type Decision = z.infer<typeof DecisionSchema>;
|
||||
export type Memory = z.infer<typeof MemorySchema>;
|
||||
export type Message = z.infer<typeof MessageSchema>;
|
||||
export type Ask = z.infer<typeof AskSchema>;
|
||||
export type Status = z.infer<typeof StatusSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@ -100,7 +100,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
||||
let summary: string;
|
||||
switch (task.status) {
|
||||
case 'in_progress':
|
||||
summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
|
||||
summary = task.claimedBy ? `Claimed by ${task.claimedBy}` : task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
|
||||
break;
|
||||
case 'review':
|
||||
summary = 'Submitted for review';
|
||||
@ -117,6 +117,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
||||
|
||||
const meta: Record<string, unknown> = { status: task.status };
|
||||
if (task.assignedTo) meta.assignedTo = task.assignedTo;
|
||||
if (task.claimedBy) meta.claimedBy = task.claimedBy;
|
||||
if (task.doneBy) meta.by = task.doneBy;
|
||||
if (task.doneTokens != null) meta.tokens = task.doneTokens;
|
||||
if (task.doneDuration != null) meta.duration = task.doneDuration;
|
||||
@ -124,7 +125,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
||||
items.push({
|
||||
at: task.updatedAt,
|
||||
kind: 'status',
|
||||
actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown',
|
||||
actor: task.doneBy ?? task.claimedBy ?? task.assignedTo ?? task.role ?? 'unknown',
|
||||
summary,
|
||||
meta,
|
||||
});
|
||||
|
||||
142
src/core/services/askService.ts
Normal file
142
src/core/services/askService.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { join } from 'path';
|
||||
import { getEntityDir } from '../paths.js';
|
||||
import { getNextId } from '../counter.js';
|
||||
import { readEntity, writeEntity } from '../files.js';
|
||||
import { AskSchema, type Ask } from '../schema.js';
|
||||
import { Index } from '../index.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
|
||||
/**
|
||||
* Ask primitive — autonomous decision-routing (TSK-0118).
|
||||
*
|
||||
* ## Authority policy
|
||||
* An Ask is the single blocking channel for a decision an agent can't make
|
||||
* alone. It flows strictly:
|
||||
*
|
||||
* implementer → ARCHITECT → (answer | escalate → CEO)
|
||||
*
|
||||
* - Implementer questions ALWAYS route to the architect (never the CEO). The
|
||||
* implementer awaits exactly ONE Ask — never a second, parallel blocking
|
||||
* channel.
|
||||
* - The architect answers most Asks himself, within the approve/push gate.
|
||||
* - The architect MUST escalate (not answer) for: release / publish / push,
|
||||
* OSS decisions, and architecture pivots. Escalation flips the Ask to
|
||||
* `escalated` (escalatedTo = 'ceo') and closes the loop through the SAME Ask —
|
||||
* the implementer keeps waiting on that one Ask, it never opens a CEO channel.
|
||||
*/
|
||||
|
||||
function indexEntryFor(record: Ask, filePath: string) {
|
||||
return {
|
||||
id: record.id,
|
||||
type: 'ask',
|
||||
title: `${record.from} → ${record.to}`,
|
||||
content: record.question,
|
||||
filePath,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
status: record.status,
|
||||
fromAgent: record.from,
|
||||
toAgent: record.to,
|
||||
taskId: record.taskId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Who an Ask routes to by default: the architect's preferred agent. NEVER the
|
||||
* CEO — a raw question is the architect's to field; only an explicit escalation
|
||||
* reaches the CEO.
|
||||
*/
|
||||
function defaultRoutedTo(cwd: string): string {
|
||||
try {
|
||||
return loadConfig(cwd).roles?.architect?.preferredAgent || 'architect';
|
||||
} catch {
|
||||
return 'architect';
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask a routed question. Defaults to the architect; a `to` of 'ceo' is refused
|
||||
* (rerouted to the architect) — the CEO is reachable only via escalateAsk. */
|
||||
export function createAsk(cwd: string, options: Partial<Ask> = {}): Ask {
|
||||
if (!options.from) throw new Error('Ask requires a "from" agent');
|
||||
if (!options.question) throw new Error('Ask requires a question');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const to = options.to && options.to.toLowerCase() !== 'ceo' ? options.to : defaultRoutedTo(cwd);
|
||||
const record: Ask = AskSchema.parse({
|
||||
id: getNextId(cwd, 'ask'),
|
||||
from: options.from,
|
||||
to,
|
||||
question: options.question,
|
||||
taskId: options.taskId,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'asks'), `${record.id}.md`);
|
||||
writeEntity(filePath, record, `# ${record.from} → ${record.to}\n\n${record.question}`);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(indexEntryFor(record, filePath), { fts: false }); // keep Asks out of FTS5
|
||||
index.close();
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
export function getAsk(cwd: string, id: string): { ask: Ask; body: string; filePath: string } {
|
||||
if (!id) throw new Error('Ask ID is required');
|
||||
const filePath = join(getEntityDir(cwd, 'asks'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
return { ask: AskSchema.parse(frontmatter), body, filePath };
|
||||
}
|
||||
|
||||
/** Asks, newest first, optionally filtered by recipient and/or status. */
|
||||
export function listAsks(cwd: string, opts: { to?: string; status?: string } = {}): Ask[] {
|
||||
const index = new Index(cwd);
|
||||
const entries = index.list('ask', opts.status ? { status: opts.status } : undefined);
|
||||
index.close();
|
||||
const ids = entries
|
||||
.filter((e) => !opts.to || (e.toAgent != null && String(e.toAgent).toLowerCase() === opts.to.toLowerCase()))
|
||||
.map((e) => e.id);
|
||||
const out: Ask[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
out.push(getAsk(cwd, id).ask);
|
||||
} catch {
|
||||
/* torn file — skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Answer an Ask (architect resolves it in the approve/push gate). */
|
||||
export function answerAsk(cwd: string, id: string, text: string, by?: string): Ask {
|
||||
if (!text || !text.trim()) throw new Error('Ask answer requires text');
|
||||
const { ask, body, filePath } = getAsk(cwd, id);
|
||||
const updated: Ask = { ...ask, status: 'answered', answer: text, answeredBy: by, updatedAt: new Date().toISOString() };
|
||||
writeEntity(filePath, updated, body);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(indexEntryFor(updated, filePath), { fts: false });
|
||||
index.close();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate an Ask to the CEO (required for release/publish/push, OSS, and
|
||||
* architecture pivots). This does NOT open a second blocking channel — it flips
|
||||
* the SAME Ask to `escalated`; the waiting implementer resolves on this one Ask.
|
||||
*/
|
||||
export function escalateAsk(cwd: string, id: string, note?: string): Ask {
|
||||
const { ask, body, filePath } = getAsk(cwd, id);
|
||||
const updated: Ask = { ...ask, status: 'escalated', escalatedTo: 'ceo', updatedAt: new Date().toISOString() };
|
||||
const newBody = note ? `${body}\n\n_escalated to ceo: ${note}_` : body;
|
||||
writeEntity(filePath, updated, newBody);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(indexEntryFor(updated, filePath), { fts: false });
|
||||
index.close();
|
||||
|
||||
return updated;
|
||||
}
|
||||
@ -34,6 +34,7 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
|
||||
to: options.to,
|
||||
text: options.text,
|
||||
taskId: options.taskId,
|
||||
replyTo: options.replyTo,
|
||||
status: 'unread',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@ -49,6 +50,17 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Agent aliases that should see the same inbox. Keep deliberately small. */
|
||||
export function messageRecipientAliases(agent: string): Set<string> {
|
||||
const key = agent.toLowerCase();
|
||||
const aliases = new Set([agent, key]);
|
||||
if (key === 'architect' || key === 'claude') {
|
||||
aliases.add('architect');
|
||||
aliases.add('claude');
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
export interface InboxMessage {
|
||||
id: string;
|
||||
from: string;
|
||||
@ -59,23 +71,46 @@ export interface InboxMessage {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Messages addressed to `agent`, newest first. */
|
||||
/**
|
||||
* Messages addressed to `agent`, newest first.
|
||||
*
|
||||
* Read-receipt side effect: any still-`unread` message that passes the filter is
|
||||
* transitioned to `delivered` (a message the recipient has now been shown) AFTER
|
||||
* filtering and BEFORE returning, so the returned rows reflect the new status.
|
||||
* This is agent-scoped only — the architect-wide `listMessages` never mutates.
|
||||
* (The `agenthub work` drainInbox path filters `unreadOnly` BEFORE this mutation
|
||||
* and then marks read, so the delivered intermediate is invisible there — no
|
||||
* regress and no spin.)
|
||||
*/
|
||||
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
|
||||
const index = new Index(cwd);
|
||||
const all = index.list('message');
|
||||
index.close();
|
||||
return all
|
||||
.filter((m) => m.toAgent === agent)
|
||||
.filter((m) => !opts.unreadOnly || m.status === 'unread')
|
||||
.map((m) => ({
|
||||
const recipients = messageRecipientAliases(agent);
|
||||
const filtered = all
|
||||
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
|
||||
.filter((m) => !opts.unreadOnly || m.status === 'unread');
|
||||
|
||||
return filtered.map((m) => {
|
||||
let status = m.status ?? 'unread';
|
||||
if (status === 'unread') {
|
||||
try {
|
||||
markMessageDelivered(cwd, m.id);
|
||||
status = 'delivered';
|
||||
} catch {
|
||||
/* best-effort: leave as unread if the file can't be updated */
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: m.id,
|
||||
from: m.fromAgent ?? '',
|
||||
to: m.toAgent ?? '',
|
||||
text: m.content,
|
||||
taskId: m.taskId,
|
||||
status: m.status ?? 'unread',
|
||||
status,
|
||||
createdAt: m.createdAt,
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** All messages (architect-visible view), newest first. */
|
||||
@ -101,6 +136,26 @@ export function getMessage(cwd: string, id: string): { message: Message; body: s
|
||||
return { message: MessageSchema.parse(frontmatter), body, filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a message from `unread` to `delivered` (the recipient has been
|
||||
* shown it). Idempotent: a message that is already delivered/read/acked is left
|
||||
* untouched — this never downgrades a stronger receipt.
|
||||
*/
|
||||
export function markMessageDelivered(cwd: string, id: string): Message {
|
||||
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
const message = MessageSchema.parse(frontmatter);
|
||||
if (message.status !== 'unread') return message;
|
||||
const updated: Message = { ...message, status: 'delivered', updatedAt: new Date().toISOString() };
|
||||
writeEntity(filePath, updated, body);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(indexEntryFor(updated, filePath));
|
||||
index.close();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Mark a message as read. */
|
||||
export function markMessageRead(cwd: string, id: string): Message {
|
||||
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
|
||||
@ -115,3 +170,22 @@ export function markMessageRead(cwd: string, id: string): Message {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge a message — the recipient has explicitly actioned it (the strongest
|
||||
* receipt). Optionally records who acked in the body trailer.
|
||||
*/
|
||||
export function ackMessage(cwd: string, id: string, by?: string): Message {
|
||||
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
|
||||
const { frontmatter, body } = readEntity(filePath);
|
||||
const message = MessageSchema.parse(frontmatter);
|
||||
const updated: Message = { ...message, status: 'acked', updatedAt: new Date().toISOString() };
|
||||
const newBody = by ? `${body}\n\n_acked by ${by}_` : body;
|
||||
writeEntity(filePath, updated, newBody);
|
||||
|
||||
const index = new Index(cwd);
|
||||
index.upsert(indexEntryFor(updated, filePath));
|
||||
index.close();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
||||
priority: options.priority ?? 'medium',
|
||||
role: options.role,
|
||||
assignedTo: options.assignedTo,
|
||||
claimedBy: options.claimedBy,
|
||||
reviewer: options.reviewer,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@ -58,8 +59,30 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a task for an agent (open → in_progress). Race-guarded: only an OPEN
|
||||
* task can be claimed. A re-claim by the SAME agent (retried request) is an
|
||||
* idempotent no-op; a task already claimed by someone else — or past `open`
|
||||
* (review/done/cancelled) — is refused, so two agents can't claim the same task
|
||||
* and a claim can't clobber another agent's in-progress work.
|
||||
*
|
||||
* `getTask`+`updateTask` are synchronous, so within the single-threaded server
|
||||
* event loop this read-check-write is effectively atomic: of two concurrent
|
||||
* claims on the same open task, the first completes the write and the second
|
||||
* then sees `in_progress` and is refused.
|
||||
*/
|
||||
export function claimTask(cwd: string, id: string, agentName: string): Task {
|
||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
|
||||
const { task } = getTask(cwd, id);
|
||||
// Idempotent: the same agent re-claiming its own in-progress task is a no-op.
|
||||
if (task.status === 'in_progress' && task.claimedBy === agentName) {
|
||||
return task;
|
||||
}
|
||||
if (task.status !== 'open') {
|
||||
throw new Error(
|
||||
`Task ${id} cannot be claimed — status is "${task.status}"${task.claimedBy ? ` (claimed by ${task.claimedBy})` : ''}.`,
|
||||
);
|
||||
}
|
||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName, claimedBy: agentName });
|
||||
}
|
||||
|
||||
export function doneTask(
|
||||
@ -141,6 +164,7 @@ function toIndexEntry(task: Task, filePath: string) {
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
claimedBy: task.claimedBy,
|
||||
reviewer: task.reviewer,
|
||||
tags: JSON.stringify(task.tags),
|
||||
};
|
||||
|
||||
@ -22,17 +22,18 @@ export function resolveAdvertiseUrl(host: string, port: number): string {
|
||||
return `http://${advertiseHost}:${port}`;
|
||||
}
|
||||
|
||||
export function startDiscoveryBroadcaster(serverUrl: string, options?: { port?: number; intervalMs?: number }) {
|
||||
export function startDiscoveryBroadcaster(getServerUrl: string | (() => string), options?: { port?: number; intervalMs?: number }) {
|
||||
const port = options?.port ?? DISCOVERY_PORT;
|
||||
const intervalMs = options?.intervalMs ?? 2000;
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const message = Buffer.from(`${DISCOVERY_PREFIX}${serverUrl}`);
|
||||
const resolveUrl = typeof getServerUrl === 'function' ? getServerUrl : () => getServerUrl;
|
||||
|
||||
socket.on('error', () => {
|
||||
// Discovery is best-effort; ignore network errors.
|
||||
});
|
||||
|
||||
const send = () => {
|
||||
const message = Buffer.from(`${DISCOVERY_PREFIX}${resolveUrl()}`);
|
||||
try {
|
||||
socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST);
|
||||
} catch {
|
||||
|
||||
@ -17,10 +17,14 @@ import {
|
||||
doneTask,
|
||||
} from '../core/services/taskService.js';
|
||||
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
||||
import { appendTaskLog } from '../core/services/taskLogService.js';
|
||||
import { createAsk, listAsks, answerAsk, escalateAsk } from '../core/services/askService.js';
|
||||
import type { Ask } from '../core/schema.js';
|
||||
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
|
||||
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
||||
import { createDecision } from '../core/services/decisionService.js';
|
||||
import { getStatus } from '../core/services/statusService.js';
|
||||
import { discoverServer as discoverHubServer } from '../discovery.js';
|
||||
|
||||
/**
|
||||
* AgentHub MCP server (TSK-0030).
|
||||
@ -51,44 +55,88 @@ function asText(value: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
|
||||
}
|
||||
|
||||
function remainingMs(deadline: number | undefined): number {
|
||||
return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now());
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function resolveReconnectUrl(currentUrl: string): Promise<string> {
|
||||
if (process.env.AGENTHUB_SERVER) return process.env.AGENTHUB_SERVER;
|
||||
const discovered = await discoverHubServer(2000);
|
||||
return discovered || currentUrl;
|
||||
}
|
||||
|
||||
/** Block on the SSE stream until findClaim() returns a task, or timeout. */
|
||||
function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, timeoutSec: number): Promise<T | null> {
|
||||
function waitForTask<T>(
|
||||
serverUrl: string,
|
||||
findClaim: (serverUrl: string) => Promise<T | null>,
|
||||
timeoutSec: number,
|
||||
): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
let controller: AbortController | undefined;
|
||||
let currentUrl = serverUrl;
|
||||
const deadline = Date.now() + Math.max(1, timeoutSec) * 1000;
|
||||
const backoffs = [2000, 5000, 10000];
|
||||
let reconnectAttempt = 0;
|
||||
const finish = (v: T | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { controller.abort(); } catch { /* already */ }
|
||||
try { controller?.abort(); } catch { /* already */ }
|
||||
resolve(v);
|
||||
};
|
||||
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
|
||||
|
||||
fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
|
||||
.then(async (res) => {
|
||||
if (!res.body) { clearTimeout(timer); finish(null); return; }
|
||||
// Close the gap: a task may have arrived between the initial check and now.
|
||||
const early = await findClaim();
|
||||
if (early) { clearTimeout(timer); finish(early); return; }
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (!settled) {
|
||||
let done: boolean; let value: Uint8Array | undefined;
|
||||
try { ({ done, value } = await reader.read()); } catch { break; }
|
||||
if (done) break;
|
||||
if (value) buffer += decoder.decode(value, { stream: true });
|
||||
const { events, remaining } = parseSSEBuffer(buffer);
|
||||
buffer = remaining;
|
||||
// Wake on a new task OR a new message addressed to the agent.
|
||||
if (events.some((e) => e.type === 'task' || e.type === 'message')) {
|
||||
const claimed = await findClaim();
|
||||
if (claimed) { clearTimeout(timer); finish(claimed); return; }
|
||||
const waitLoop = async () => {
|
||||
while (!settled && remainingMs(deadline) > 0) {
|
||||
controller = new AbortController();
|
||||
try {
|
||||
const res = await fetch(new URL('/events', currentUrl).toString(), {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!res.body) throw new Error('SSE response has no body');
|
||||
// Close the gap: a task may have arrived between the initial check and now.
|
||||
const early = await findClaim(currentUrl);
|
||||
if (early) { clearTimeout(timer); finish(early); return; }
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
reconnectAttempt = 0;
|
||||
while (!settled) {
|
||||
let done: boolean; let value: Uint8Array | undefined;
|
||||
try { ({ done, value } = await reader.read()); } catch { break; }
|
||||
if (done) break;
|
||||
if (value) buffer += decoder.decode(value, { stream: true });
|
||||
const { events, remaining } = parseSSEBuffer(buffer);
|
||||
buffer = remaining;
|
||||
// Wake on a new task, a message, or an ask (decision routed to the
|
||||
// architect / answered back to the asker).
|
||||
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
|
||||
const claimed = await findClaim(currentUrl);
|
||||
if (claimed) { clearTimeout(timer); finish(claimed); return; }
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
|
||||
}
|
||||
clearTimeout(timer); finish(null);
|
||||
})
|
||||
.catch(() => { clearTimeout(timer); finish(null); });
|
||||
if (settled || remainingMs(deadline) <= 0) break;
|
||||
currentUrl = await resolveReconnectUrl(currentUrl);
|
||||
const backoff = backoffs[Math.min(reconnectAttempt, backoffs.length - 1)] ?? 10_000;
|
||||
reconnectAttempt += 1;
|
||||
await sleep(Math.min(backoff, remainingMs(deadline)));
|
||||
}
|
||||
clearTimeout(timer);
|
||||
finish(null);
|
||||
};
|
||||
|
||||
waitLoop().catch(() => {
|
||||
clearTimeout(timer);
|
||||
finish(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -106,63 +154,82 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
||||
|
||||
server.tool('agenthub_work',
|
||||
'Block until there is work for you, then return it. IMPLEMENTER: waits for a task addressed to you (newly delegated OR reopened), claims it, returns it with its handoff (loop: work -> implement -> agenthub_task_review -> work). ARCHITECT/REVIEWER: waits for any task submitted to review and returns the pending set to approve (agenthub_task_done) or reject (agenthub_task_reopen) — so review submissions reach you in realtime.',
|
||||
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
|
||||
async ({ agent, role, timeoutSec }) => {
|
||||
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional(), unattended: z.boolean().optional() },
|
||||
async ({ agent, role, timeoutSec, unattended }) => {
|
||||
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
||||
const reviewer = isReviewerRole(ctx.role);
|
||||
const useServerUrl = (nextServerUrl?: string) => {
|
||||
if (nextServerUrl) ctx.serverUrl = nextServerUrl;
|
||||
return ctx.serverUrl!;
|
||||
};
|
||||
// Fetch + mark-read the agent's unread messages, so the work loop surfaces
|
||||
// them once and doesn't spin on the same message.
|
||||
const drainInbox = async () => {
|
||||
const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
||||
const drainInbox = async (nextServerUrl?: string) => {
|
||||
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
|
||||
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
||||
for (const m of msgs) {
|
||||
try { if (remote) await remoteClient.markMessageRead(serverUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
|
||||
try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
|
||||
}
|
||||
return msgs;
|
||||
};
|
||||
const findWork = async () => {
|
||||
const findWork = async (nextServerUrl?: string) => {
|
||||
if (nextServerUrl) useServerUrl(nextServerUrl);
|
||||
const found = await findAddressedOpenTask(ctx);
|
||||
if (found) {
|
||||
if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent);
|
||||
if (remote) await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
|
||||
else claimTask(root, found.task.id, agent);
|
||||
const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id);
|
||||
const detail = remote ? await remoteClient.getTask(ctx.serverUrl!, found.task.id) : getTask(root, found.task.id);
|
||||
const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
|
||||
let handoff: unknown = null;
|
||||
if (hofEntry) {
|
||||
try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
|
||||
try { handoff = remote ? await remoteClient.getHandoff(ctx.serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
|
||||
}
|
||||
return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox() };
|
||||
return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox(ctx.serverUrl) };
|
||||
}
|
||||
const messages = await drainInbox();
|
||||
const messages = await drainInbox(ctx.serverUrl);
|
||||
if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' };
|
||||
return null;
|
||||
};
|
||||
// Architect/reviewer variant: wake on tasks submitted to review (not on
|
||||
// tasks addressed to you). Returns the pending review set — never claims.
|
||||
const findReview = async () => {
|
||||
const listPendingAsks = async (): Promise<Ask[]> =>
|
||||
remote ? await remoteClient.listAsks(ctx.serverUrl!, { status: 'pending' }) : listAsks(root, { status: 'pending' });
|
||||
const findReview = async (nextServerUrl?: string) => {
|
||||
if (nextServerUrl) useServerUrl(nextServerUrl);
|
||||
const reviews = await listReviewTasks(ctx);
|
||||
const messages = await drainInbox();
|
||||
if (reviews.length) {
|
||||
const messages = await drainInbox(ctx.serverUrl);
|
||||
const asks = await listPendingAsks();
|
||||
if (reviews.length || asks.length) {
|
||||
return {
|
||||
reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })),
|
||||
asks: asks.map((a) => ({ id: a.id, from: a.from, question: a.question, taskId: a.taskId })),
|
||||
messages,
|
||||
note: 'Tasks awaiting your review. Approve: agenthub_task_done. Reject: agenthub_task_reopen (send feedback first via agenthub_message or a handoff). Read the work with agenthub_task_show.',
|
||||
note: 'Tasks awaiting your review (approve: agenthub_task_done; reject: agenthub_task_reopen). Pending asks: answer with agenthub_ask_answer, or escalate to the CEO with agenthub_ask_escalate (release/publish/push, OSS, architecture pivots).',
|
||||
};
|
||||
}
|
||||
if (messages.length) return { reviews: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
|
||||
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
|
||||
return null;
|
||||
};
|
||||
|
||||
const finder: () => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork;
|
||||
const finder: (nextServerUrl?: string) => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork;
|
||||
|
||||
// Self-perpetuating loop: every response reminds the agent to relaunch
|
||||
// agenthub_work, so a finished task/message never leaves it dormant.
|
||||
const LOOP = reviewer
|
||||
let LOOP = reviewer
|
||||
? 'STAY REACHABLE: the instant you finish reviewing, call agenthub_work again — ' +
|
||||
'run it in the background so the next review submission reaches you in realtime. ' +
|
||||
'run it in the background so the next review submission or ask reaches you in realtime. ' +
|
||||
'Never end your turn without relaunching agenthub_work.'
|
||||
: 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' +
|
||||
'run it in the background so the next task or message reaches you in realtime. ' +
|
||||
'Report meaningful progress with agenthub_task_log while you work. ' +
|
||||
'ESPECIALLY after you submit with agenthub_task_review: do NOT end your turn — ' +
|
||||
'immediately relaunch agenthub_work and keep it running, so the architect\'s approval, ' +
|
||||
'reopen (with feedback) or follow-up message wakes you here instead of leaving you dormant. ' +
|
||||
'Never end your turn without relaunching agenthub_work.';
|
||||
if (unattended) {
|
||||
LOOP += ' UNATTENDED MODE: never pause for human input — when you need a decision, ' +
|
||||
'call agenthub_ask (it routes to the architect) and await the answer instead of stalling.';
|
||||
}
|
||||
const immediate = await finder();
|
||||
if (immediate) return asText({ ...immediate, loop: LOOP });
|
||||
const emptyMsg = reviewer
|
||||
@ -211,6 +278,11 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
||||
{ id: z.string() },
|
||||
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
||||
|
||||
server.tool('agenthub_task_log',
|
||||
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").',
|
||||
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
|
||||
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
|
||||
|
||||
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
|
||||
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
|
||||
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
|
||||
@ -245,6 +317,36 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
||||
{ agent: z.string(), unreadOnly: z.boolean().optional() },
|
||||
async ({ agent, unreadOnly }) => asText(remote ? await remoteClient.getInbox(serverUrl!, agent, unreadOnly) : listInbox(root, agent, { unreadOnly })));
|
||||
|
||||
server.tool('agenthub_ask',
|
||||
'Ask the architect a blocking question when you cannot decide alone (autonomous decision-routing). Routes to the architect — NEVER the CEO. With wait:true it blocks until the architect answers or escalates, then returns the resolution. Await exactly ONE ask at a time.',
|
||||
{ from: z.string(), question: z.string(), taskId: z.string().optional(), wait: z.boolean().optional(), timeoutSec: z.number().optional() },
|
||||
async ({ from, question, taskId, wait, timeoutSec }) => {
|
||||
const ask = remote ? await remoteClient.createAsk(serverUrl!, { from, question, taskId }) : createAsk(root, { from, question, taskId });
|
||||
if (!wait) return asText(ask);
|
||||
if (!remote) return asText({ ...ask, note: 'Created; --wait needs a running hub server.' });
|
||||
const settled = await waitForTask<Ask>(serverUrl!, async (url) => {
|
||||
const { ask: cur } = await remoteClient.getAsk(url, ask.id);
|
||||
return cur.status !== 'pending' ? cur : null;
|
||||
}, timeoutSec ?? 300);
|
||||
if (settled) return asText(settled);
|
||||
return asText({ ...ask, note: `No answer within ${timeoutSec ?? 300}s — re-check with agenthub_ask_list.` });
|
||||
});
|
||||
|
||||
server.tool('agenthub_ask_list',
|
||||
'List asks (routed decisions). Architect: your pending decision queue. Filter by status (e.g. pending) and/or recipient.',
|
||||
{ to: z.string().optional(), status: z.string().optional() },
|
||||
async ({ to, status }) => asText(remote ? await remoteClient.listAsks(serverUrl!, { to, status }) : listAsks(root, { to, status })));
|
||||
|
||||
server.tool('agenthub_ask_answer',
|
||||
'Answer an ask (architect resolves a routed decision within the approve/push gate).',
|
||||
{ id: z.string(), text: z.string(), by: z.string().optional() },
|
||||
async ({ id, text, by }) => asText(remote ? await remoteClient.answerAsk(serverUrl!, id, text, by) : answerAsk(root, id, text, by)));
|
||||
|
||||
server.tool('agenthub_ask_escalate',
|
||||
'Escalate an ask to the CEO (architect: REQUIRED for release/publish/push, OSS decisions and architecture pivots). Closes the loop through the same ask — no second blocking channel.',
|
||||
{ id: z.string(), note: z.string().optional() },
|
||||
async ({ id, note }) => asText(remote ? await remoteClient.escalateAsk(serverUrl!, id, note) : escalateAsk(root, id, note)));
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
// stdio servers must not write to stdout (it's the protocol channel); log to stderr.
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { listMessages } from '../core/services/messageService.js';
|
||||
import { listTasks } from '../core/services/taskService.js';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
||||
import type { IndexEntry } from '../core/index.js';
|
||||
@ -36,18 +35,10 @@ function snippet(text: string, max = 120): string {
|
||||
export function renderActivityHtml(cwd: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const tasks = listTasks(cwd);
|
||||
const messages = listMessages(cwd);
|
||||
const done = tasks.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
// Messaging lives on its own /messages page now — /activity is tasks-only.
|
||||
const activityRows = [
|
||||
...messages.map((m) => ({
|
||||
at: m.createdAt,
|
||||
html: `<article class="activity-row">
|
||||
<span class="kind kind-message">message</span>
|
||||
<div class="activity-main"><span class="id">${escapeHtml(m.id)}</span> ${escapeHtml(m.from || '?')} to ${escapeHtml(m.to || '?')}: ${escapeHtml(snippet(m.text))}</div>
|
||||
<span class="state">${m.status === 'read' ? `read by ${escapeHtml(m.to || '?')}` : '<span class="unread-dot" aria-hidden="true"></span>unread'}</span>
|
||||
</article>`,
|
||||
})),
|
||||
...tasks.map((t) => ({
|
||||
at: t.updatedAt || t.createdAt,
|
||||
html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}">
|
||||
@ -95,10 +86,8 @@ export function renderActivityHtml(cwd: string): string {
|
||||
.activity-row { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:start;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
|
||||
.activity-row:first-child { border-top:0;padding-top:0; }
|
||||
.kind { font:10px/1.4 var(--font-mono);border-radius:999px;padding:1px 6px;border:1px solid var(--border);white-space:nowrap; }
|
||||
.kind-message { color:var(--accent);border-color:rgba(88,166,255,.32);background:rgba(88,166,255,.08); }
|
||||
.kind-task { color:var(--status-review);border-color:rgba(210,153,34,.32);background:rgba(210,153,34,.08); }
|
||||
.activity-main,.title { min-width:0;overflow-wrap:anywhere; }
|
||||
.unread-dot { display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-right:4px; }
|
||||
.task-row { display:grid;grid-template-columns:82px minmax(0,1fr) 74px auto;gap:10px;align-items:center;color:inherit;text-decoration:none;border-top:1px solid var(--border);padding:8px 0; }
|
||||
.task-row:first-child { border-top:0;padding-top:0; }
|
||||
.task-row:hover,.activity-row:hover { color:var(--text); }
|
||||
|
||||
1995
src/server/board.ts
1995
src/server/board.ts
File diff suppressed because it is too large
Load Diff
43
src/server/board/chrome.ts
Normal file
43
src/server/board/chrome.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { appHeader } from '../ui-shared.js';
|
||||
|
||||
/**
|
||||
* Fixed app header for board v2 — the shared v2 header (ui-shared.appHeader)
|
||||
* with the board marked active. The project cell is visual only (dropdown = v3);
|
||||
* SSE ids (`sseStatus`, `sseLabel`) match the ported connection-status JS.
|
||||
* Styling comes from ui-shared's appHeaderOnlyCss(), which boardV2Css() includes.
|
||||
*/
|
||||
export function headerHtml(projectName: string): string {
|
||||
return appHeader(projectName, 'board');
|
||||
}
|
||||
|
||||
/** Fullscreen splash overlay, shown until first data render (hard cap 1.5s). */
|
||||
export function splashHtml(): string {
|
||||
return `
|
||||
<div id="b2-splash" class="b2-splash" aria-hidden="true">
|
||||
<div class="b2-splash-inner">
|
||||
<img src="/logo.svg" alt="agenthub" width="72" height="72" class="b2-splash-logo">
|
||||
<div class="b2-splash-word">agenthub</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script: hides the splash after the first successful refresh() or after
|
||||
* a hard 1500ms cap, whichever comes first. Exposes window.__b2SplashDone() so
|
||||
* the data layer can signal completion.
|
||||
*/
|
||||
export function splashJs(): string {
|
||||
return `
|
||||
(function () {
|
||||
var done = false;
|
||||
window.__b2SplashDone = function () {
|
||||
if (done) return;
|
||||
done = true;
|
||||
var s = document.getElementById('b2-splash');
|
||||
if (!s) return;
|
||||
s.classList.add('b2-splash-hide');
|
||||
setTimeout(function () { if (s.parentNode) s.parentNode.removeChild(s); }, 450);
|
||||
};
|
||||
setTimeout(window.__b2SplashDone, 1500); // hard cap — splash must never block
|
||||
})();`;
|
||||
}
|
||||
897
src/server/board/columns.ts
Normal file
897
src/server/board/columns.ts
Normal file
@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Board v2 columns/cards/modals/data-layer: a faithful port of the v1 client
|
||||
* JS (old src/server/board.ts, lines 975–1460 and 1752–2032) with these
|
||||
* deliberate changes:
|
||||
* - v1 KPI metric cards + Lottie icons are gone; renderBoard() instead calls
|
||||
* window.__b2UpdateKpis / __b2RenderThroughput (kpis.ts / sidebar.ts).
|
||||
* - taskCard() adds the b2-working glow and the in-progress PROGRESS RING
|
||||
* (elapsed vs median-done-duration estimate; amber when over estimate).
|
||||
* - SSE task events additionally push a line into the live feed
|
||||
* (window.__b2FeedPush, sidebar.ts).
|
||||
* - The init sequence is NOT included here (see initJs below) so index.ts
|
||||
* can run it after all module scripts are defined.
|
||||
* Everything else (patchColumn reconcile, drag & drop, race-guarded moves,
|
||||
* live console, modals, toasts, connection state) is byte-faithful to v1.
|
||||
*/
|
||||
export interface BoardColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Column skeletons; mount points match the ported JS (data-cards/data-count). */
|
||||
export function columnsHtml(columns: BoardColumnDef[]): string {
|
||||
const cols = columns
|
||||
.map(
|
||||
(c) => ` <section class="column b2-glass" data-column="${c.key}" aria-label="${c.label}">
|
||||
<header class="col-head"><span class="col-dot" aria-hidden="true"></span><span class="col-label">${c.label}</span><span class="col-count" data-count="${c.key}">0</span></header>
|
||||
<div class="cards" data-cards="${c.key}"><div class="empty">none</div></div>
|
||||
</section>`,
|
||||
)
|
||||
.join('\n');
|
||||
return `<section class="board" id="board" aria-label="Task board">\n${cols}\n </section>`;
|
||||
}
|
||||
|
||||
/** Modal markup (new task / delete confirm / task detail) + toast host, from v1. */
|
||||
export function modalsHtml(): string {
|
||||
return `
|
||||
<div class="modal-backdrop" id="taskModal" hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tmHeading">
|
||||
<div class="modal-head">
|
||||
<h2 id="tmHeading">New task</h2>
|
||||
<button class="modal-x" id="tmClose" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form id="tmForm" autocomplete="off">
|
||||
<label class="modal-field">
|
||||
<span class="modal-label">Title</span>
|
||||
<input id="tmTitleInput" type="text" placeholder="What needs doing?" required maxlength="140" />
|
||||
</label>
|
||||
<label class="modal-field">
|
||||
<span class="modal-label">Description</span>
|
||||
<textarea id="tmDesc" rows="4" placeholder="Context, acceptance criteria, links… (optional)"></textarea>
|
||||
</label>
|
||||
<label class="modal-field modal-field-inline">
|
||||
<span class="modal-label">Priority</span>
|
||||
<select id="tmPriority">
|
||||
<option value="low">low</option>
|
||||
<option value="medium" selected>medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="critical">critical</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="modal-note">Created unassigned — the architect picks it up and delegates it.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
|
||||
<button type="submit" class="modal-create">Create task</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="confirmModal" hidden>
|
||||
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="cfHeading" aria-describedby="cfBody">
|
||||
<div class="modal-head">
|
||||
<h2 id="cfHeading">Delete task</h2>
|
||||
<button class="modal-x" id="cfClose" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="confirm-body">
|
||||
<p id="cfBody">This cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" id="cfCancel">Cancel</button>
|
||||
<button type="button" class="modal-danger" id="cfConfirm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="detailModal" hidden>
|
||||
<div class="modal detail-modal" role="dialog" aria-modal="true" aria-labelledby="dtHeading">
|
||||
<div class="modal-head">
|
||||
<h2 id="dtHeading">Task detail</h2>
|
||||
<button class="modal-x" id="dtClose" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="detail-body" id="dtBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>`;
|
||||
}
|
||||
|
||||
export interface ColumnsJsOpts {
|
||||
columns: string[];
|
||||
activeColumns: string[];
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
/** The ported client JS (no init sequence — index.ts appends initJs last). */
|
||||
export function columnsJs(opts: ColumnsJsOpts): string {
|
||||
return `
|
||||
var REFRESH_MS = 6000;
|
||||
var TIMER_MS = 1000;
|
||||
var COLUMNS = ${JSON.stringify(opts.columns)};
|
||||
var ACTIVE_COLUMNS = ${JSON.stringify(opts.activeColumns)};
|
||||
var PROJECT_NAME = ${JSON.stringify(opts.projectName)};
|
||||
var lastTasks = [];
|
||||
var openConsoles = {}; // taskId -> true when its in-card console is expanded
|
||||
var estimatedMs = 3600000; // progress-ring estimate; recomputed per refresh
|
||||
var CONN_CACHE_KEY = 'agenthub-connection-state';
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
function compactDuration(ms) {
|
||||
var s = Math.max(0, Math.floor(ms / 1000));
|
||||
if (s < 60) return s + 's';
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm';
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 48) return h + 'h';
|
||||
return Math.floor(h / 24) + 'd';
|
||||
}
|
||||
function ago(iso) {
|
||||
var t = Date.parse(iso);
|
||||
if (isNaN(t)) return '';
|
||||
return compactDuration(Date.now() - t) + ' ago';
|
||||
}
|
||||
async function getJSON(path) {
|
||||
var res = await fetch(path, { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
function taskPath(id) {
|
||||
return '/tasks/' + encodeURIComponent(id);
|
||||
}
|
||||
function statusLabel(status) {
|
||||
return String(status || 'open').replace(/_/g, ' ');
|
||||
}
|
||||
function hashColor(name) {
|
||||
var colors = ['#0EA5E9', '#14B8A6', '#F59E0B', '#EF4444', '#8B5CF6', '#64748B'];
|
||||
var h = 0;
|
||||
var s = String(name || '');
|
||||
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
return colors[Math.abs(h) % colors.length];
|
||||
}
|
||||
function agentSpec(name) {
|
||||
var key = String(name || '').toLowerCase();
|
||||
var map = {
|
||||
claude: { color: '#D97757', initials: 'C', architect: true },
|
||||
codex: { color: '#10A37F', initials: 'Cx' },
|
||||
kimi: { color: '#7C3AED', initials: 'K' },
|
||||
'windows-claude': { color: '#2563EB', initials: 'W' },
|
||||
backyard: { color: '#64748B', initials: 'B' }
|
||||
};
|
||||
if (map[key]) return map[key];
|
||||
var clean = key.replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
return {
|
||||
color: hashColor(key),
|
||||
initials: (clean ? clean.split(' ').map(function(p) { return p[0]; }).join('').slice(0, 2) : '?').toUpperCase()
|
||||
};
|
||||
}
|
||||
function agentAvatar(name, role) {
|
||||
if (!name) return '<span class="avatar" style="--agent-color:#64748B"><span class="avatar-core">?</span><span class="avatar-label">unassigned</span></span>';
|
||||
var spec = agentSpec(name);
|
||||
var isArchitect = spec.architect || String(role || '').toLowerCase() === 'architect';
|
||||
return '<span class="avatar' + (isArchitect ? ' architect' : '') + '" style="--agent-color:' + esc(spec.color) + '">' +
|
||||
'<span class="avatar-core">' + esc(spec.initials) + '</span>' +
|
||||
'<span class="avatar-label">@' + esc(name) + '</span>' +
|
||||
'</span>';
|
||||
}
|
||||
function reviewerBadge(name) {
|
||||
if (!name) return '';
|
||||
return '<span class="reviewer-wrap"><span class="reviewer-label">reviewed by</span>' + agentAvatar(name, 'reviewer') + '</span>';
|
||||
}
|
||||
function projectTag(title) {
|
||||
var s = String(title || '').toLowerCase();
|
||||
if (s.indexOf('win') >= 0 || s.indexOf('windows') >= 0) return 'windows';
|
||||
if (s.indexOf('backend') >= 0 || s.indexOf('api') >= 0) return 'backend';
|
||||
if (s.indexOf('magic') >= 0) return 'magic';
|
||||
if (s.indexOf('agenthub') >= 0 || s.indexOf('board') >= 0 || s.indexOf('ui') >= 0) return 'agenthub';
|
||||
return PROJECT_NAME;
|
||||
}
|
||||
function timerLabel(status, createdAt, updatedAt) {
|
||||
var created = Date.parse(createdAt);
|
||||
var updated = Date.parse(updatedAt || createdAt);
|
||||
if (isNaN(created)) return '';
|
||||
var key = String(status || 'open');
|
||||
if (key === 'done' || key === 'cancelled') {
|
||||
return 'total ' + compactDuration((isNaN(updated) ? Date.now() : updated) - created);
|
||||
}
|
||||
if (key === 'in_progress') return 'claimed ' + ago(updatedAt || createdAt);
|
||||
if (key === 'review') return 'review ' + ago(updatedAt || createdAt);
|
||||
return 'created ' + ago(createdAt);
|
||||
}
|
||||
function updateTimers() {
|
||||
document.querySelectorAll('[data-timer]').forEach(function(el) {
|
||||
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
|
||||
});
|
||||
// Progress rings on in-progress cards: elapsed vs estimated duration.
|
||||
var est = Math.max(60000, estimatedMs);
|
||||
document.querySelectorAll('[data-ring]').forEach(function(el) {
|
||||
var claimed = Date.parse(el.getAttribute('data-claimed') || '');
|
||||
if (isNaN(claimed)) return;
|
||||
var elapsed = Math.max(0, Date.now() - claimed);
|
||||
var pct = Math.min(1, elapsed / est);
|
||||
var fill = el.querySelector('.b2-ring-fill');
|
||||
if (fill) fill.style.strokeDashoffset = String((50.3 * (1 - pct)).toFixed(1));
|
||||
var t = el.querySelector('[data-ring-time]');
|
||||
if (t) t.textContent = compactDuration(elapsed) + ' / ~' + compactDuration(est);
|
||||
el.classList.toggle('b2-over', elapsed > est);
|
||||
});
|
||||
}
|
||||
function byStatus(status) {
|
||||
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
|
||||
}
|
||||
|
||||
function taskCard(t) {
|
||||
var status = byStatus(t.status);
|
||||
var isOpen = openConsoles[t.id] ? true : false;
|
||||
var live = status === 'in_progress';
|
||||
// Console lives inside the card while a task is worked (in_progress = live)
|
||||
// and stays available in review so you can see what the agent did.
|
||||
var console = (live || status === 'review')
|
||||
? '<div class="card-console-wrap">' +
|
||||
'<button class="card-console-toggle" type="button" data-console-toggle="' + esc(t.id) + '" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
|
||||
(live ? '<span class="cc-dot" aria-hidden="true"></span>' : '') + (live ? 'live console' : 'agent console') +
|
||||
'<span class="cc-chevron" aria-hidden="true">' + (isOpen ? '\\u25be' : '\\u25b8') + '</span>' +
|
||||
'</button>' +
|
||||
'<div class="card-console" data-console-for="' + esc(t.id) + '"' + (isOpen ? '' : ' hidden') + '>' +
|
||||
'<div class="card-console-body" data-console-body="' + esc(t.id) + '"><div class="cc-empty">waiting for output…</div></div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
: '';
|
||||
// Progress ring on in-progress (work time) AND review cards (wait time) —
|
||||
// same design, review variant in amber. Filled = elapsed vs median estimate.
|
||||
var ringStatus = live ? 'work' : status === 'review' ? 'review' : '';
|
||||
var ringSince = live ? (t.claimedAt || t.updatedAt || t.createdAt || '') : (t.updatedAt || t.createdAt || '');
|
||||
var ring = ringStatus
|
||||
? '<span class="b2-ring' + (ringStatus === 'review' ? ' b2-ring-review' : '') + '" data-ring data-claimed="' + esc(ringSince) + '">' +
|
||||
'<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">' +
|
||||
'<circle class="b2-ring-track" cx="10" cy="10" r="8" stroke-width="3"/>' +
|
||||
'<circle class="b2-ring-fill" cx="10" cy="10" r="8" stroke-width="3" stroke-dasharray="50.3" stroke-dashoffset="50.3"/>' +
|
||||
'</svg>' +
|
||||
'<span class="b2-ring-time" data-ring-time></span>' +
|
||||
'</span>'
|
||||
: '';
|
||||
return '<a class="card' + (live ? ' b2-working' : '') + '" draggable="true" href="/tasks/' + encodeURIComponent(t.id) + '"' +
|
||||
' data-id="' + esc(t.id) + '" data-status="' + esc(status) + '" data-assigned="' + esc(t.assignedTo || '') + '" data-reviewer="' + esc(t.reviewer || '') + '">' +
|
||||
'<div class="card-top">' +
|
||||
'<span class="id">' + esc(t.id) + '</span>' +
|
||||
'<span class="card-top-right">' +
|
||||
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
|
||||
'<button class="card-del" type="button" data-del="' + esc(t.id) + '" title="Delete task permanently" aria-label="Delete ' + esc(t.id) + '">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14"/></svg>' +
|
||||
'</button>' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<h3 class="title">' + esc(t.title) + '</h3>' +
|
||||
'<div class="meta-row">' +
|
||||
(status === 'review' && t.reviewer
|
||||
? reviewerBadge(t.reviewer)
|
||||
: agentAvatar(t.assignedTo, t.role)) +
|
||||
'<span class="pill timer-badge" data-timer data-status="' + esc(status) + '" data-created="' + esc(t.createdAt) + '" data-updated="' + esc(t.updatedAt) + '">' +
|
||||
esc(timerLabel(status, t.createdAt, t.updatedAt)) +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
ring +
|
||||
console +
|
||||
'</a>';
|
||||
}
|
||||
// Median real duration of recently done tasks — the progress ring's "typical
|
||||
// duration" estimate. Fallback 60 min (mirrors the budget-service live cap).
|
||||
function estimateDurationMs(tasks) {
|
||||
var ds = [];
|
||||
(tasks || []).forEach(function(t) {
|
||||
if (t.status !== 'done') return;
|
||||
var start = Date.parse(t.claimedAt || t.createdAt || '');
|
||||
var end = Date.parse(t.updatedAt || '');
|
||||
if (!isNaN(start) && !isNaN(end) && end > start) ds.push(end - start);
|
||||
});
|
||||
if (!ds.length) return 3600000;
|
||||
ds.sort(function(a, b) { return a - b; });
|
||||
return ds[Math.floor(ds.length / 2)];
|
||||
}
|
||||
// Signature of everything that changes a card's rendered CONTENT — but NOT
|
||||
// its console open/closed state (that lives in the DOM + openConsoles and
|
||||
// must survive a patch). Identical signature ⇒ the card node is left alone.
|
||||
function cardSig(t) {
|
||||
return [byStatus(t.status), t.title || '', t.assignedTo || '', t.reviewer || '', t.role || '', t.createdAt || '', t.updatedAt || ''].join('\\u0001');
|
||||
}
|
||||
function cardNode(t, sig, isNew) {
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = taskCard(t);
|
||||
var node = tmp.firstElementChild;
|
||||
if (!node) return null;
|
||||
node.setAttribute('data-sig', sig);
|
||||
if (isNew) {
|
||||
node.classList.add('card-new');
|
||||
// One-shot marker: drop it once the enter animation has played, so a
|
||||
// later reorder (insertBefore) can never replay the animation.
|
||||
setTimeout(function() { node.classList.remove('card-new'); }, 300);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
// Reconcile ONE column's DOM against its desired task list WITHOUT a full
|
||||
// innerHTML= replace (which re-created every node on every event → board
|
||||
// twitch + card overlap from the global enter animation). Only genuinely
|
||||
// new/changed cards are (re)built; untouched cards are kept and merely
|
||||
// reordered, so open live consoles + hover/animation state survive.
|
||||
function patchColumn(cardsEl, list) {
|
||||
if (!list.length) {
|
||||
if (cardsEl.children.length !== 1 || !cardsEl.querySelector('.empty')) {
|
||||
cardsEl.innerHTML = '<div class="empty">none</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
var placeholder = cardsEl.querySelector('.empty');
|
||||
if (placeholder) placeholder.remove();
|
||||
var existing = {};
|
||||
cardsEl.querySelectorAll('.card[data-id]').forEach(function(node) {
|
||||
existing[node.getAttribute('data-id')] = node;
|
||||
});
|
||||
var seen = {};
|
||||
var prev = null;
|
||||
list.forEach(function(t) {
|
||||
seen[t.id] = true;
|
||||
var sig = cardSig(t);
|
||||
var old = existing[t.id];
|
||||
var node;
|
||||
if (old && old.getAttribute('data-sig') === sig) {
|
||||
node = old; // unchanged — keep node + its console
|
||||
} else {
|
||||
node = cardNode(t, sig, !old); // new-to-this-column ⇒ enter animation
|
||||
if (old) old.remove(); // drop the stale version
|
||||
if (!node) return;
|
||||
}
|
||||
// Place node immediately after the previous card so DOM order == list.
|
||||
var anchor = prev ? prev.nextSibling : cardsEl.firstChild;
|
||||
if (node !== anchor) cardsEl.insertBefore(node, anchor);
|
||||
prev = node;
|
||||
});
|
||||
// Remove cards that left this column (moved elsewhere or were deleted).
|
||||
Object.keys(existing).forEach(function(id) {
|
||||
if (!seen[id]) existing[id].remove();
|
||||
});
|
||||
}
|
||||
function renderBoard(tasks) {
|
||||
lastTasks = tasks || [];
|
||||
estimatedMs = estimateDurationMs(lastTasks);
|
||||
var byCol = {};
|
||||
ACTIVE_COLUMNS.forEach(function(k) { byCol[k] = []; });
|
||||
lastTasks.forEach(function(t) {
|
||||
var key = byStatus(t.status);
|
||||
if (byCol[key]) byCol[key].push(t);
|
||||
});
|
||||
ACTIVE_COLUMNS.forEach(function(k) {
|
||||
var list = byCol[k];
|
||||
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
|
||||
var count = document.querySelector('[data-count="' + k + '"]');
|
||||
if (count) count.textContent = String(list.length);
|
||||
if (!cardsEl) return;
|
||||
patchColumn(cardsEl, list);
|
||||
});
|
||||
updateTimers();
|
||||
reapplyConsoles();
|
||||
if (window.__b2UpdateKpis) {
|
||||
window.__b2UpdateKpis(lastTasks, function(name) { return agentSpec(name).color; });
|
||||
}
|
||||
if (window.__b2RenderThroughput) window.__b2RenderThroughput(lastTasks);
|
||||
}
|
||||
|
||||
// ── In-card live agent console ──────────────────────────────────────────
|
||||
function consoleLineHtml(e) {
|
||||
var lvl = String(e.level || 'info');
|
||||
var ts = '';
|
||||
try { ts = new Date(e.ts).toLocaleTimeString(); } catch (_) {}
|
||||
return '<div class="cc-line level-' + esc(lvl) + '">' +
|
||||
(ts ? '<span class="cc-ts">' + esc(ts) + '</span>' : '') +
|
||||
(e.agent ? '<span class="cc-agent">' + esc(e.agent) + '</span>' : '') +
|
||||
'<span class="cc-text">' + esc(e.text) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
function renderConsole(id, entries) {
|
||||
var body = document.querySelector('[data-console-body="' + id + '"]');
|
||||
if (!body) return;
|
||||
if (!entries || !entries.length) { body.innerHTML = '<div class="cc-empty">waiting for output…</div>'; return; }
|
||||
body.innerHTML = entries.map(consoleLineHtml).join('');
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
function detailActivityHtml(a) {
|
||||
var when = '';
|
||||
try { when = a.at ? ago(a.at) : ''; } catch (_) {}
|
||||
return '<article class="detail-activity-row">' +
|
||||
'<span class="detail-when">' + esc(when) + '</span>' +
|
||||
'<span class="detail-kind">' + esc(a.kind || '') + '</span>' +
|
||||
'<div class="detail-summary">' + esc(a.summary || '') + '</div>' +
|
||||
'<span class="detail-actor">' + esc(a.actor || '') + '</span>' +
|
||||
'</article>';
|
||||
}
|
||||
function renderTaskDetailModal(id, detail, activity) {
|
||||
var body = document.getElementById('dtBody');
|
||||
var heading = document.getElementById('dtHeading');
|
||||
if (!body) return;
|
||||
var task = detail && detail.task ? detail.task : {};
|
||||
var desc = String(detail && detail.body ? detail.body : '').trim();
|
||||
if (heading) heading.textContent = task.id ? task.id : 'Task detail';
|
||||
var status = byStatus(task.status);
|
||||
var sections = [
|
||||
'<div class="detail-topline"><span class="id">' + esc(task.id || id) + '</span><span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' + (task.assignedTo ? agentAvatar(task.assignedTo, task.role) : '') + '</div>',
|
||||
'<h1 class="detail-title">' + esc(task.title || id) + '</h1>'
|
||||
];
|
||||
if (desc) {
|
||||
sections.push('<section class="detail-section"><h3>Description</h3><pre class="detail-description">' + esc(desc) + '</pre></section>');
|
||||
}
|
||||
if (activity && activity.length) {
|
||||
sections.push('<section class="detail-section"><h3>Activity</h3><div class="detail-activity">' + activity.map(detailActivityHtml).join('') + '</div></section>');
|
||||
}
|
||||
body.innerHTML = sections.join('');
|
||||
}
|
||||
async function openTaskDetail(id) {
|
||||
var modal = document.getElementById('detailModal');
|
||||
var body = document.getElementById('dtBody');
|
||||
if (!modal || !body) return;
|
||||
modal.hidden = false;
|
||||
body.innerHTML = '<div class="empty">loading…</div>';
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
getJSON(taskPath(id)),
|
||||
getJSON(taskPath(id) + '/activity')
|
||||
]);
|
||||
renderTaskDetailModal(id, results[0], results[1] || []);
|
||||
} catch (err) {
|
||||
body.innerHTML = '<div class="detail-error">Could not load ' + esc(id) + '.</div>';
|
||||
}
|
||||
}
|
||||
function closeTaskDetail() {
|
||||
var modal = document.getElementById('detailModal');
|
||||
if (modal) modal.hidden = true;
|
||||
}
|
||||
async function loadConsole(id) {
|
||||
try {
|
||||
var data = await getJSON(taskPath(id) + '/log');
|
||||
renderConsole(id, data && data.log ? data.log : []);
|
||||
} catch (_) {}
|
||||
}
|
||||
function appendConsoleLine(id, entry) {
|
||||
if (!openConsoles[id]) return;
|
||||
var body = document.querySelector('[data-console-body="' + id + '"]');
|
||||
if (!body) return;
|
||||
var empty = body.querySelector('.cc-empty');
|
||||
if (empty) body.innerHTML = '';
|
||||
body.insertAdjacentHTML('beforeend', consoleLineHtml(entry));
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
// Re-open any consoles that were expanded before a re-render (cards rebuild
|
||||
// their innerHTML, so the panel state must be re-applied + reloaded).
|
||||
function reapplyConsoles() {
|
||||
Object.keys(openConsoles).forEach(function(id) {
|
||||
if (!openConsoles[id]) return;
|
||||
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
||||
if (panel) { panel.hidden = false; loadConsole(id); }
|
||||
});
|
||||
}
|
||||
function setConn(state, label) {
|
||||
var el = document.getElementById('sseStatus');
|
||||
var text = document.getElementById('sseLabel');
|
||||
if (!el || !text) return;
|
||||
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
|
||||
text.textContent = label || (state === 'ok' ? 'connected' : state === 'stale' ? 'connecting' : 'offline');
|
||||
try { sessionStorage.setItem(CONN_CACHE_KEY, JSON.stringify({ state: state, label: text.textContent, at: Date.now() })); } catch (_) {}
|
||||
}
|
||||
function restoreConn() {
|
||||
try {
|
||||
var cached = JSON.parse(sessionStorage.getItem(CONN_CACHE_KEY) || 'null');
|
||||
if (cached && cached.at && Date.now() - cached.at < 30000) {
|
||||
setConn(cached.state || 'stale', cached.label || 'connected');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
function applyProjectName(name) {
|
||||
PROJECT_NAME = name || PROJECT_NAME;
|
||||
var el = document.getElementById('projectName');
|
||||
if (el) el.textContent = PROJECT_NAME;
|
||||
}
|
||||
async function loadStatusMeta() {
|
||||
try {
|
||||
var status = await getJSON('/status');
|
||||
var body = String(status && status.body ? status.body : '');
|
||||
var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status[^\\S\\n]*[:\\-][^\\S\\n]*(.+)$/m);
|
||||
var name = m && m[1] ? m[1].trim() : '';
|
||||
// Guard: never let a summary line (long / contains a period) become the name.
|
||||
applyProjectName(name && name.length <= 48 && name.indexOf('.') < 0 ? name : PROJECT_NAME);
|
||||
} catch (_) {
|
||||
applyProjectName(PROJECT_NAME);
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
try {
|
||||
var tasks = await getJSON('/tasks');
|
||||
renderBoard(tasks);
|
||||
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
||||
if (window.__b2SplashDone) window.__b2SplashDone();
|
||||
} catch (_) {
|
||||
setConn('down', 'offline');
|
||||
}
|
||||
}
|
||||
var eventSourceReady = false;
|
||||
var eventSource = null;
|
||||
var fallbackPollTimer = null;
|
||||
function startFallbackPoll() {
|
||||
if (fallbackPollTimer) return;
|
||||
fallbackPollTimer = setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
function stopFallbackPoll() {
|
||||
if (!fallbackPollTimer) return;
|
||||
clearInterval(fallbackPollTimer);
|
||||
fallbackPollTimer = null;
|
||||
}
|
||||
// Compact one-liner for the sidebar live feed (defensive about payload shape).
|
||||
function feedFromEvent(d) {
|
||||
if (!window.__b2FeedPush) return;
|
||||
var id = (d && (d.id || d.taskId || (d.task && d.task.id))) || 'task';
|
||||
var status = (d && (d.status || d.to || (d.task && d.task.status))) || '';
|
||||
var agent = (d && (d.agent || d.assignedTo)) || '';
|
||||
window.__b2FeedPush('<span class="pulse">●</span><span>' + esc(id) + (status ? ' → ' + esc(status) : ' updated') + (agent ? ' · @' + esc(agent) : '') + '</span>');
|
||||
}
|
||||
function connectEvents() {
|
||||
if (!('EventSource' in window)) {
|
||||
setConn('stale', 'polling');
|
||||
startFallbackPoll();
|
||||
return;
|
||||
}
|
||||
if (eventSource) eventSource.close();
|
||||
var source = new EventSource('/events');
|
||||
eventSource = source;
|
||||
source.onopen = function() {
|
||||
eventSourceReady = true;
|
||||
stopFallbackPoll();
|
||||
setConn('ok', 'connected');
|
||||
refresh();
|
||||
refreshBudget();
|
||||
};
|
||||
source.onmessage = function(ev) {
|
||||
eventSourceReady = true;
|
||||
stopFallbackPoll();
|
||||
setConn('ok', 'connected');
|
||||
// Only task lifecycle changes affect the board. message / ask / decision /
|
||||
// memory / handoff / agent-presence events must NOT trigger a board
|
||||
// rerender — the old unconditional refresh() on every change was the root
|
||||
// cause of the board twitch + card overlap. The budget panel has its own
|
||||
// diff + throttle (shouldRenderDonut / FLIP bars), so it can refresh on
|
||||
// any change without churn.
|
||||
var type = '';
|
||||
var data = null;
|
||||
try { data = JSON.parse(ev && ev.data); type = (data || {}).type || ''; } catch (_) {}
|
||||
if (type === 'task') { refresh(); feedFromEvent(data); }
|
||||
refreshBudget();
|
||||
};
|
||||
source.onerror = function() {
|
||||
eventSourceReady = false;
|
||||
setConn('stale', 'reconnecting');
|
||||
startFallbackPoll();
|
||||
};
|
||||
// Named task-log events feed the in-card live console without a re-render.
|
||||
source.addEventListener('task-log', function(ev) {
|
||||
try {
|
||||
var p = JSON.parse(ev.data);
|
||||
if (p && p.taskId) appendConsoleLine(p.taskId, p);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
function closeEvents() {
|
||||
stopFallbackPoll();
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
}
|
||||
window.addEventListener('pagehide', closeEvents);
|
||||
window.addEventListener('beforeunload', closeEvents);
|
||||
window.addEventListener('pageshow', function() {
|
||||
refresh();
|
||||
refreshBudget();
|
||||
if (!eventSource) connectEvents();
|
||||
});
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) return;
|
||||
refresh();
|
||||
refreshBudget();
|
||||
if (!eventSource) connectEvents();
|
||||
});
|
||||
window.addEventListener('focus', function() {
|
||||
refresh();
|
||||
refreshBudget();
|
||||
});
|
||||
|
||||
// ── Toasts ──────────────────────────────────────────────────────────────
|
||||
function toast(msg, opts) {
|
||||
opts = opts || {};
|
||||
var wrap = document.getElementById('toasts');
|
||||
if (!wrap) return;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'toast' + (opts.error ? ' err' : '');
|
||||
el.innerHTML = '<span class="dot" aria-hidden="true"></span><span>' + esc(msg) + '</span>';
|
||||
wrap.appendChild(el);
|
||||
setTimeout(function() {
|
||||
el.classList.add('out');
|
||||
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 240);
|
||||
}, opts.ms || 3200);
|
||||
}
|
||||
|
||||
// ── Mutations (create / assign / move) ──────────────────────────────────
|
||||
async function postTask(body) {
|
||||
var res = await fetch('/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error('create failed (' + res.status + ')');
|
||||
return res.json();
|
||||
}
|
||||
async function patchTask(id, body) {
|
||||
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) {
|
||||
var msg = 'update failed (' + res.status + ')';
|
||||
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
function flashCard(id) {
|
||||
var el = document.querySelector('.card[data-id="' + id + '"]');
|
||||
if (!el) return;
|
||||
el.classList.add('flash');
|
||||
setTimeout(function() { el.classList.remove('flash'); }, 900);
|
||||
}
|
||||
// Derive the addressed agent from a task title's "name:" prefix (delegation
|
||||
// convention), matched case-insensitively against the known roster.
|
||||
function agentFromTitle(id) {
|
||||
var t = (lastTasks || []).find(function(x) { return x.id === id; });
|
||||
if (!t || !t.title) return '';
|
||||
var m = String(t.title).match(/^([A-Za-z][A-Za-z0-9_-]*)\\s*:/);
|
||||
if (!m) return '';
|
||||
var name = m[1].toLowerCase();
|
||||
var hit = (AGENTS || []).find(function(a) { return String(a.name).toLowerCase() === name; });
|
||||
return hit ? hit.name : '';
|
||||
}
|
||||
|
||||
async function onDropColumn(id, status, assigned) {
|
||||
var card = document.querySelector('.card[data-id="' + id + '"]');
|
||||
var from = card ? card.dataset.status : '';
|
||||
if (from === status) return;
|
||||
try {
|
||||
if (status === 'in_progress') {
|
||||
// Claiming needs an agent: use the current assignee, else the one named
|
||||
// in the title, else mark it as a manual board claim.
|
||||
var titledAgent = assigned ? '' : agentFromTitle(id);
|
||||
var agent = assigned || titledAgent || 'manual';
|
||||
await patchTask(id, { status: 'in_progress', assignedTo: agent });
|
||||
toast(id + ' \\u2192 in progress \\u00b7 @' + agent + (titledAgent ? ' (from title)' : agent === 'manual' ? ' (manual)' : ''));
|
||||
} else if (status === 'review') {
|
||||
var reviewed = await patchTask(id, { status: 'review' });
|
||||
toast(id + ' \\u2192 review' + (reviewed && reviewed.reviewer ? ' \\u00b7 reviewed by @' + reviewed.reviewer : ''));
|
||||
} else if (status === 'open') {
|
||||
await patchTask(id, { status: 'open' });
|
||||
toast(id + ' \\u2192 reopened');
|
||||
} else { return; }
|
||||
await refresh(); await refreshBudget(); flashCard(id);
|
||||
} catch (e) { toast(e.message || 'move failed', { error: true }); }
|
||||
}
|
||||
|
||||
// ── Drag & drop wiring (event delegation) ───────────────────────────────
|
||||
// Drag a CARD onto a COLUMN to change its status (in_progress needs an agent).
|
||||
var dragKind = null, dragId = null, lastTarget = null;
|
||||
function dropTargetOf(e) {
|
||||
var n = e.target;
|
||||
if (!n || !n.closest) return null;
|
||||
if (dragKind === 'card') return n.closest('.column');
|
||||
return null;
|
||||
}
|
||||
document.addEventListener('dragstart', function(e) {
|
||||
// Don't start a card drag when interacting with the in-card console.
|
||||
if (e.target.closest && e.target.closest('.card-console-wrap')) { e.preventDefault(); return; }
|
||||
var card = e.target.closest && e.target.closest('.card');
|
||||
if (card) {
|
||||
dragKind = 'card'; dragId = card.dataset.id;
|
||||
card.classList.add('dragging');
|
||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', dragId); } catch (_) {} }
|
||||
}
|
||||
});
|
||||
document.addEventListener('dragend', function(e) {
|
||||
var el = e.target.closest && e.target.closest('.card');
|
||||
if (el) {
|
||||
el.classList.remove('dragging');
|
||||
// Guard the trailing click so a drag doesn't also open the task detail.
|
||||
el.classList.add('just-dragged');
|
||||
setTimeout(function() { el.classList.remove('just-dragged'); }, 350);
|
||||
}
|
||||
if (lastTarget) { lastTarget.classList.remove('drop-active'); lastTarget = null; }
|
||||
dragKind = null; dragId = null;
|
||||
});
|
||||
document.addEventListener('dragover', function(e) {
|
||||
if (!dragKind) return;
|
||||
var tgt = dropTargetOf(e);
|
||||
if (!tgt) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
if (lastTarget && lastTarget !== tgt) lastTarget.classList.remove('drop-active');
|
||||
tgt.classList.add('drop-active');
|
||||
lastTarget = tgt;
|
||||
});
|
||||
document.addEventListener('dragleave', function(e) {
|
||||
var tgt = dropTargetOf(e);
|
||||
if (tgt && e.relatedTarget && !tgt.contains(e.relatedTarget)) {
|
||||
tgt.classList.remove('drop-active');
|
||||
if (lastTarget === tgt) lastTarget = null;
|
||||
}
|
||||
});
|
||||
document.addEventListener('drop', function(e) {
|
||||
if (!dragKind) return;
|
||||
var tgt = dropTargetOf(e);
|
||||
if (!tgt) return;
|
||||
e.preventDefault();
|
||||
tgt.classList.remove('drop-active');
|
||||
var card = document.querySelector('.card[data-id="' + dragId + '"]');
|
||||
var assigned = card ? (card.dataset.assigned || '') : '';
|
||||
onDropColumn(dragId, tgt.dataset.column, assigned);
|
||||
});
|
||||
// A drag that ends in a real drop still fires a click on the <a> card — swallow it.
|
||||
document.addEventListener('click', function(e) {
|
||||
var card = e.target.closest && e.target.closest('.card');
|
||||
if (card && card.classList.contains('just-dragged')) { e.preventDefault(); card.classList.remove('just-dragged'); }
|
||||
}, true);
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest && e.target.closest('.card-del, [data-console-toggle], .card-console, .card-console-wrap')) return;
|
||||
var card = e.target.closest && e.target.closest('.card');
|
||||
if (!card || card.classList.contains('just-dragged')) return;
|
||||
e.preventDefault();
|
||||
openTaskDetail(card.dataset.id);
|
||||
});
|
||||
|
||||
// ── Delete a task (permanent) ───────────────────────────────────────────
|
||||
async function deleteTask(id) {
|
||||
try {
|
||||
var res = await fetch('/tasks/' + encodeURIComponent(id), { method: 'DELETE', headers: { accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
var msg = 'delete failed (' + res.status + ')';
|
||||
try { var j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
toast(id + ' deleted');
|
||||
await refresh(); await refreshBudget();
|
||||
} catch (e) { toast(e.message || 'delete failed', { error: true }); }
|
||||
}
|
||||
// Themed confirm dialog (replaces window.confirm). Resolves true on confirm.
|
||||
function confirmModal(opts) {
|
||||
opts = opts || {};
|
||||
return new Promise(function(resolve) {
|
||||
var modal = document.getElementById('confirmModal');
|
||||
if (!modal) { resolve(window.confirm(opts.bodyText || 'Are you sure?')); return; }
|
||||
var heading = document.getElementById('cfHeading');
|
||||
var body = document.getElementById('cfBody');
|
||||
var okBtn = document.getElementById('cfConfirm');
|
||||
var cancelBtn = document.getElementById('cfCancel');
|
||||
var closeBtn = document.getElementById('cfClose');
|
||||
if (heading) heading.textContent = opts.title || 'Confirm';
|
||||
if (body) body.innerHTML = opts.bodyHtml || esc(opts.bodyText || '');
|
||||
if (okBtn) okBtn.textContent = opts.confirmLabel || 'Confirm';
|
||||
var done = false;
|
||||
function cleanup(result) {
|
||||
if (done) return; done = true;
|
||||
modal.hidden = true;
|
||||
okBtn && okBtn.removeEventListener('click', onOk);
|
||||
cancelBtn && cancelBtn.removeEventListener('click', onCancel);
|
||||
closeBtn && closeBtn.removeEventListener('click', onCancel);
|
||||
modal.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup(true); }
|
||||
function onCancel() { cleanup(false); }
|
||||
function onBackdrop(e) { if (e.target === modal) cleanup(false); }
|
||||
function onKey(e) { if (e.key === 'Escape') cleanup(false); }
|
||||
okBtn && okBtn.addEventListener('click', onOk);
|
||||
cancelBtn && cancelBtn.addEventListener('click', onCancel);
|
||||
closeBtn && closeBtn.addEventListener('click', onCancel);
|
||||
modal.addEventListener('click', onBackdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
modal.hidden = false;
|
||||
if (okBtn) setTimeout(function() { okBtn.focus(); }, 20);
|
||||
});
|
||||
}
|
||||
// Delete button lives inside the card <a> — stop the navigation + confirm.
|
||||
document.addEventListener('click', function(e) {
|
||||
var del = e.target.closest && e.target.closest('.card-del');
|
||||
if (!del) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var id = del.dataset.del;
|
||||
if (!id) return;
|
||||
confirmModal({
|
||||
title: 'Delete ' + id + '?',
|
||||
bodyHtml: 'This permanently removes <b>' + esc(id) + '</b> — its file and index entry. This cannot be undone.',
|
||||
confirmLabel: 'Delete task',
|
||||
}).then(function(ok) { if (ok) deleteTask(id); });
|
||||
});
|
||||
|
||||
// Clicks/selection inside the console body must not navigate to the task page.
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest && e.target.closest('.card-console')) e.preventDefault();
|
||||
});
|
||||
// Live-console toggle lives inside the card <a> — stop the navigation.
|
||||
document.addEventListener('click', function(e) {
|
||||
var tog = e.target.closest && e.target.closest('[data-console-toggle]');
|
||||
if (!tog) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var id = tog.getAttribute('data-console-toggle');
|
||||
var willOpen = !openConsoles[id];
|
||||
openConsoles[id] = willOpen;
|
||||
tog.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||
var chev = tog.querySelector('.cc-chevron'); if (chev) chev.textContent = willOpen ? '\\u25be' : '\\u25b8';
|
||||
var panel = document.querySelector('.card-console[data-console-for="' + id + '"]');
|
||||
if (panel) panel.hidden = !willOpen;
|
||||
if (willOpen) loadConsole(id);
|
||||
});
|
||||
|
||||
// ── Task-detail modal ──────────────────────────────────────────────────
|
||||
(function() {
|
||||
var modal = document.getElementById('detailModal');
|
||||
if (!modal) return;
|
||||
var close = document.getElementById('dtClose');
|
||||
if (close) close.addEventListener('click', closeTaskDetail);
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeTaskDetail(); });
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeTaskDetail(); });
|
||||
})();
|
||||
|
||||
// ── New-task modal ──────────────────────────────────────────────────────
|
||||
(function() {
|
||||
var btn = document.getElementById('newTaskBtn');
|
||||
var modal = document.getElementById('taskModal');
|
||||
var form = document.getElementById('tmForm');
|
||||
if (!btn || !modal || !form) return;
|
||||
var titleInput = document.getElementById('tmTitleInput');
|
||||
function openModal() {
|
||||
modal.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
if (titleInput) setTimeout(function() { titleInput.focus(); }, 20);
|
||||
}
|
||||
function closeModal() {
|
||||
modal.hidden = true;
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
form.reset();
|
||||
}
|
||||
btn.addEventListener('click', openModal);
|
||||
document.getElementById('tmClose').addEventListener('click', closeModal);
|
||||
document.getElementById('tmCancel').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && !modal.hidden) closeModal(); });
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
var title = (titleInput.value || '').trim();
|
||||
if (!title) return;
|
||||
var description = (document.getElementById('tmDesc').value || '').trim();
|
||||
var priority = document.getElementById('tmPriority').value || undefined;
|
||||
try {
|
||||
// No assignee/role: the task lands open in the architect's lap to route.
|
||||
var task = await postTask({ title: title, description: description, priority: priority });
|
||||
toast(task.id + ' created');
|
||||
closeModal();
|
||||
await refresh(); await refreshBudget(); flashCard(task.id);
|
||||
} catch (err) { toast(err.message || 'create failed', { error: true }); }
|
||||
});
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Init sequence — MUST run after every module script above is defined
|
||||
* (refreshBudget comes from v1Budget.ts, __b2UpdateKpis from kpis.ts, …).
|
||||
* index.ts appends this as the last chunk of the single inline script.
|
||||
*/
|
||||
export function initJs(): string {
|
||||
return `
|
||||
loadStatusMeta();
|
||||
restoreConn();
|
||||
loadAgents();
|
||||
refresh();
|
||||
refreshBudget();
|
||||
connectEvents();
|
||||
setInterval(updateTimers, TIMER_MS);
|
||||
setInterval(refreshBudget, 3000);
|
||||
`;
|
||||
}
|
||||
55
src/server/board/index.ts
Normal file
55
src/server/board/index.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { boardV2Css } from './styles.js';
|
||||
import { headerHtml, splashHtml, splashJs } from './chrome.js';
|
||||
import { kpiSkeletonHtml, kpiJs } from './kpis.js';
|
||||
import { sidebarHtml, sidebarJs } from './sidebar.js';
|
||||
import { columnsHtml, columnsJs, modalsHtml, initJs, type BoardColumnDef } from './columns.js';
|
||||
import { v1BudgetJs } from './v1Budget.js';
|
||||
|
||||
/** All statuses the data layer understands (order = board semantics). */
|
||||
export const BOARD_COLUMNS: BoardColumnDef[] = [
|
||||
{ key: 'open', label: 'Open' },
|
||||
{ key: 'in_progress', label: 'In Progress' },
|
||||
{ key: 'review', label: 'Review' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
/** The three lanes actually rendered as columns. */
|
||||
export const ACTIVE_COLUMNS: BoardColumnDef[] = BOARD_COLUMNS.slice(0, 3);
|
||||
|
||||
/**
|
||||
* Board v2: glass dashboard rendered once at server start; all data arrives
|
||||
* via same-origin fetches + SSE. The single inline script is ordered so the
|
||||
* init sequence (initJs) runs after every module's definitions exist.
|
||||
*/
|
||||
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>agenthub — Board</title>
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||
<style>${boardV2Css()}</style>
|
||||
</head>
|
||||
<body class="b2">
|
||||
${splashHtml()}
|
||||
${headerHtml(projectName)}
|
||||
<main class="b2-body">
|
||||
<div class="b2-main">
|
||||
${kpiSkeletonHtml()}
|
||||
${columnsHtml(ACTIVE_COLUMNS)}
|
||||
</div>
|
||||
${sidebarHtml()}
|
||||
</main>
|
||||
${modalsHtml()}
|
||||
<script>${splashJs()}</script>
|
||||
<script>${columnsJs({
|
||||
columns: BOARD_COLUMNS.map((c) => c.key),
|
||||
activeColumns: ACTIVE_COLUMNS.map((c) => c.key),
|
||||
projectName,
|
||||
})}${kpiJs()}${sidebarJs(v1BudgetJs)}${initJs()}</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
101
src/server/board/kpis.ts
Normal file
101
src/server/board/kpis.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import {
|
||||
capChips, doneStats, backlogSeries, areaPath, laneChips, dayStart,
|
||||
} from './viewmodel.js';
|
||||
|
||||
/** Static skeleton: four KPI cards; values are filled client-side. */
|
||||
export function kpiSkeletonHtml(): string {
|
||||
return `
|
||||
<section class="b2-kpis" aria-label="Kennzahlen">
|
||||
<div class="b2-kpi b2-glass" id="kpiOpen">
|
||||
<div class="b2-lbl">Open</div>
|
||||
<div class="b2-val"><span data-k="count">–</span> <small data-k="total"></small></div>
|
||||
<div class="b2-miniarea" data-k="area"></div>
|
||||
</div>
|
||||
<div class="b2-kpi b2-glass" id="kpiInProgress">
|
||||
<div class="b2-lbl">In Progress</div>
|
||||
<div class="b2-val"><span data-k="count">–</span></div>
|
||||
<div class="b2-chips" data-k="chips"></div>
|
||||
</div>
|
||||
<div class="b2-kpi b2-glass" id="kpiReview">
|
||||
<div class="b2-lbl">Review</div>
|
||||
<div class="b2-val"><span data-k="count">–</span></div>
|
||||
<div class="b2-chips" data-k="chips"></div>
|
||||
</div>
|
||||
<div class="b2-kpi b2-glass" id="kpiDone">
|
||||
<div class="b2-lbl">Done</div>
|
||||
<div class="b2-val"><span data-k="count">–</span> <small data-k="total"></small></div>
|
||||
<div class="b2-cap-r" data-k="cap"></div>
|
||||
<div class="b2-pbar"><i data-k="bar"></i></div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script: injects the pure viewmodel helpers (toString) plus the shared
|
||||
* DAY_MS/dayStart they reference, and defines window.__b2UpdateKpis(tasks,
|
||||
* agentColor), called by the data layer on every refresh. agentColor(name)
|
||||
* comes from the columns module (existing per-agent palette).
|
||||
*/
|
||||
export function kpiJs(): string {
|
||||
return `
|
||||
var DAY_MS = 24 * 3600 * 1000;
|
||||
${dayStart.toString()}
|
||||
${capChips.toString()}
|
||||
${doneStats.toString()}
|
||||
${backlogSeries.toString()}
|
||||
${areaPath.toString()}
|
||||
${laneChips.toString()}
|
||||
window.__b2UpdateKpis = function (tasks, agentColor) {
|
||||
var kpiPrev = window.__b2KpiPrev || (window.__b2KpiPrev = {});
|
||||
function flashKpi(card, value) {
|
||||
if (kpiPrev[card] !== undefined && kpiPrev[card] !== value) {
|
||||
var cardEl = document.getElementById(card);
|
||||
if (cardEl) { cardEl.classList.remove('metric-flash'); void cardEl.offsetWidth; cardEl.classList.add('metric-flash'); }
|
||||
}
|
||||
kpiPrev[card] = value;
|
||||
}
|
||||
function setK(card, key, fn) {
|
||||
var el = document.querySelector('#' + card + ' [data-k="' + key + '"]');
|
||||
if (el) fn(el);
|
||||
}
|
||||
// Open
|
||||
var open = tasks.filter(function (t) { return t.status === 'open'; });
|
||||
setK('kpiOpen', 'count', function (el) { el.textContent = String(open.length); });
|
||||
flashKpi('kpiOpen', String(open.length));
|
||||
setK('kpiOpen', 'total', function (el) {
|
||||
el.textContent = '/ ' + tasks.filter(function (t) { return t.status !== 'cancelled'; }).length;
|
||||
});
|
||||
setK('kpiOpen', 'area', function (el) {
|
||||
var p = areaPath(backlogSeries(tasks, 14), 120, 26);
|
||||
el.innerHTML = '<svg width="100%" height="26" viewBox="0 0 120 26" preserveAspectRatio="none">'
|
||||
+ '<path fill="rgba(148,163,184,.18)" d="' + p.area + '"/>'
|
||||
+ '<path class="b2-spark" fill="none" stroke="#94a3b8" stroke-width="1.5" d="' + p.line + '"/></svg>';
|
||||
});
|
||||
// In Progress + Review chips (max 3, then +n)
|
||||
['in_progress', 'review'].forEach(function (status) {
|
||||
var card = status === 'review' ? 'kpiReview' : 'kpiInProgress';
|
||||
var chips = laneChips(tasks, status);
|
||||
var capped = capChips(chips, 3);
|
||||
setK(card, 'count', function (el) { el.textContent = String(chips.length); });
|
||||
flashKpi(card, String(chips.length));
|
||||
setK(card, 'chips', function (el) {
|
||||
el.innerHTML = capped.visible.map(function (c) {
|
||||
var color = agentColor ? agentColor(c.name) : '#a5b4fc';
|
||||
var initial = c.name.charAt(0).toUpperCase();
|
||||
var time = c.minutes == null ? '' : (status === 'review' ? 'prüft ' : '') + c.minutes + 'm';
|
||||
var dot = status === 'in_progress' ? '<span class="b2-cdot"></span>' : '';
|
||||
return '<span class="b2-achip"><i style="background:' + color + '">' + initial + '</i>' + dot + time + '</span>';
|
||||
}).join('') + (capped.hidden > 0 ? '<span class="b2-achip b2-more">+' + capped.hidden + '</span>' : '');
|
||||
});
|
||||
});
|
||||
// Done
|
||||
var ds = doneStats(tasks);
|
||||
setK('kpiDone', 'count', function (el) { el.textContent = String(ds.done); });
|
||||
flashKpi('kpiDone', String(ds.done));
|
||||
setK('kpiDone', 'total', function (el) { el.textContent = '/ ' + ds.total; });
|
||||
setK('kpiDone', 'cap', function (el) {
|
||||
el.textContent = ds.pct + '% · +' + ds.doneThisWeek + ' diese Woche';
|
||||
});
|
||||
setK('kpiDone', 'bar', function (el) { el.style.width = ds.pct + '%'; });
|
||||
};`;
|
||||
}
|
||||
65
src/server/board/sidebar.ts
Normal file
65
src/server/board/sidebar.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { throughputSeries, areaPath, dayStart } from './viewmodel.js';
|
||||
|
||||
/**
|
||||
* Sidebar skeleton: budget card + live feed card. The budget card has no tab
|
||||
* bar of its own — the Token/Kosten/Verlauf tabs live inside the donut card
|
||||
* (rendered client-side by the ported v1 budget JS in v1Budget.ts). The
|
||||
* subhead keeps the v1 mount points: [data-budget-mode] (Session/Gesamt),
|
||||
* #budgetTotal, #budgetReset; #budgetRows is where the donut card mounts.
|
||||
*/
|
||||
export function sidebarHtml(): string {
|
||||
return `
|
||||
<aside class="b2-side">
|
||||
<div class="b2-panel b2-glass" id="b2Budget">
|
||||
<div class="b2-subhead">
|
||||
<h6>Budget</h6>
|
||||
<span class="seg" aria-label="Token insight range">
|
||||
<button type="button" data-budget-mode="session" class="active">Session</button>
|
||||
<button type="button" data-budget-mode="total">Gesamt</button>
|
||||
</span>
|
||||
<span class="total" id="budgetTotal"></span>
|
||||
<button type="button" class="reset-btn" id="budgetReset">Reset</button>
|
||||
</div>
|
||||
<div id="budgetRows"><div class="budget-empty">no agent activity yet</div></div>
|
||||
</div>
|
||||
<div class="b2-panel b2-glass">
|
||||
<h6>Live</h6>
|
||||
<div class="b2-feed" id="b2Feed"></div>
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script: throughput area chart renderer (used by the Verlauf tab in
|
||||
* the donut card) + live feed, followed by the ported v1 budget JS.
|
||||
*/
|
||||
export function sidebarJs(v1BudgetJs: string): string {
|
||||
return `
|
||||
var DAY_MS = 24 * 3600 * 1000;
|
||||
${dayStart.toString()}
|
||||
${throughputSeries.toString()}
|
||||
${areaPath.toString()}
|
||||
window.__b2RenderThroughput = function (tasks) {
|
||||
var box = document.getElementById('b2Throughput');
|
||||
var tip = document.getElementById('b2ThroughputTip');
|
||||
if (!box) return; // Verlauf tab not mounted right now — nothing to update
|
||||
var s = throughputSeries(tasks, 14);
|
||||
var p = areaPath(s, 220, 80);
|
||||
box.innerHTML = '<svg width="100%" height="80" viewBox="0 0 220 80" preserveAspectRatio="none">'
|
||||
+ '<path fill="rgba(56,189,248,.25)" d="' + p.area + '"/>'
|
||||
+ '<path class="b2-spark" fill="none" stroke="#38bdf8" stroke-width="1.6" d="' + p.line + '"/></svg>';
|
||||
if (tip) {
|
||||
var avg = s.reduce(function (a, b) { return a + b; }, 0) / s.length;
|
||||
tip.textContent = 'Erledigte Tasks · 14 Tage · Ø ' + avg.toFixed(1) + '/Tag';
|
||||
}
|
||||
};
|
||||
window.__b2FeedPush = function (html) {
|
||||
var feed = document.getElementById('b2Feed');
|
||||
if (!feed) return;
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
feed.insertBefore(div, feed.firstChild);
|
||||
while (feed.children.length > 5) feed.removeChild(feed.lastChild);
|
||||
};
|
||||
${v1BudgetJs}`;
|
||||
}
|
||||
430
src/server/board/styles.ts
Normal file
430
src/server/board/styles.ts
Normal file
@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Board v2 design system: glass dashboard on a deep gradient.
|
||||
* Everything is emitted by boardV2Css() and inlined into the page <style>.
|
||||
* The header CSS is shared with all pages and comes from ui-shared
|
||||
* (appHeaderOnlyCss) — this file only adds the board-specific components.
|
||||
*
|
||||
* Naming: `b2-` prefix for new v2 components (kpis, sidebar tabs,
|
||||
* feed, splash, progress ring). Interaction-critical components ported from
|
||||
* v1 (cards, columns, console, modals, toasts, budget internals) keep their
|
||||
* v1 class names so the ported client JS keeps working unchanged — they read
|
||||
* the same CSS variables, which are redefined here with the v2 glass values.
|
||||
*/
|
||||
import { appHeaderOnlyCss } from '../ui-shared.js';
|
||||
|
||||
export function boardV2Css(): string {
|
||||
return appHeaderOnlyCss() + `
|
||||
:root {
|
||||
/* v2 tokens */
|
||||
--b2-bg: #090c18;
|
||||
--b2-surface: rgba(255,255,255,.045);
|
||||
--b2-raised: rgba(255,255,255,.055);
|
||||
--b2-border: rgba(255,255,255,.09);
|
||||
--b2-text: #eef1f8;
|
||||
--b2-muted: #8fa0c6;
|
||||
--b2-dim: #5b6a8c;
|
||||
--b2-accent: #38bdf8;
|
||||
--b2-violet: #8b5cf6;
|
||||
--b2-green: #34d399;
|
||||
--b2-amber: #fbbf24;
|
||||
--b2-open: #8b949e;
|
||||
/* v1 aliases (ported components read these) */
|
||||
--bg: var(--b2-bg);
|
||||
--surface: var(--b2-surface);
|
||||
--raised: var(--b2-raised);
|
||||
--border: var(--b2-border);
|
||||
--text: var(--b2-text);
|
||||
--muted: var(--b2-muted);
|
||||
--accent: var(--b2-accent);
|
||||
--green: var(--b2-green);
|
||||
--open: #8B949E;
|
||||
--in_progress: #38bdf8;
|
||||
--review: #fbbf24;
|
||||
--done: #34d399;
|
||||
--cancelled: #6E7681;
|
||||
--danger: #F85149;
|
||||
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; height: 100%; scrollbar-gutter: stable; }
|
||||
html { overflow: hidden; }
|
||||
body {
|
||||
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%) fixed;
|
||||
color: var(--text);
|
||||
font: 15px/1.5 var(--sans);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 84px 22px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
button, a, .card { cursor: pointer; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:focus-visible, .card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* ── Keyframes (v2) ───────────────────────────────────────────────── */
|
||||
@keyframes b2-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes b2-fill { from { width: 0; } }
|
||||
@keyframes b2-blink { 50% { opacity: .3; } }
|
||||
@keyframes b2-draw { to { stroke-dashoffset: 0; } }
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────────────── */
|
||||
.b2-body {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) minmax(290px, 330px);
|
||||
gap: 16px; min-height: 0; flex: 1 1 auto;
|
||||
}
|
||||
.b2-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; min-height: 0; height: 100%; }
|
||||
.b2-glass {
|
||||
background: var(--b2-surface);
|
||||
border: 1px solid var(--b2-border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* ── KPI row ──────────────────────────────────────────────────────── */
|
||||
.b2-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; flex: 0 0 auto; }
|
||||
.b2-kpi { padding: 12px 15px; position: relative; overflow: hidden; animation: b2-rise .6s both; }
|
||||
.b2-kpi:nth-child(2) { animation-delay: .07s; }
|
||||
.b2-kpi:nth-child(3) { animation-delay: .14s; }
|
||||
.b2-kpi:nth-child(4) { animation-delay: .21s; }
|
||||
.b2-lbl { color: var(--b2-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
|
||||
.b2-val { font-size: 26px; font-weight: 700; margin-top: 3px; font-family: var(--mono); }
|
||||
.b2-val small { font-size: 13px; color: var(--b2-muted); font-weight: 500; }
|
||||
.b2-miniarea { margin-top: 8px; }
|
||||
.b2-spark { stroke-dasharray: 400; stroke-dashoffset: 400; animation: b2-draw 1.6s .3s forwards ease-out; }
|
||||
.b2-chips { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: wrap; min-height: 24px; }
|
||||
.b2-achip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font: 11px/1 var(--mono); padding: 3px 9px 3px 4px;
|
||||
border-radius: 999px; border: 1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.05); color: var(--b2-text);
|
||||
}
|
||||
.b2-achip i {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-style: normal; font-size: 9px; font-weight: 800; color: #0e1226;
|
||||
}
|
||||
.b2-achip.b2-more { padding: 3px 9px; color: var(--b2-muted); }
|
||||
.b2-cdot { width: 6px; height: 6px; border-radius: 50%; background: var(--b2-green); animation: b2-blink 1.6s infinite; }
|
||||
.b2-cap-r { text-align: right; font-size: 11px; color: var(--b2-muted); margin: 8px 0 3px; font-family: var(--mono); }
|
||||
.b2-pbar { height: 5px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; }
|
||||
.b2-pbar i { display: block; height: 100%; border-radius: 999px;
|
||||
background: linear-gradient(90deg, #34d399, #10b981);
|
||||
animation: b2-fill 1.3s .4s both; transition: width 600ms ease; }
|
||||
/* value-change pulse (ported behaviour, new look) */
|
||||
.b2-kpi.metric-flash { animation: metricFlash 900ms ease; }
|
||||
@keyframes metricFlash {
|
||||
0% { background: rgba(56,189,248,.14); box-shadow: 0 0 0 2px rgba(56,189,248,.35); }
|
||||
100% { background: var(--b2-surface); box-shadow: 0 0 0 0 rgba(56,189,248,0); }
|
||||
}
|
||||
|
||||
/* ── Board columns & task cards (ported v1, glass look) ───────────── */
|
||||
.board {
|
||||
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px; align-items: stretch; min-width: 0; min-height: 0; flex: 1 1 auto;
|
||||
}
|
||||
.column {
|
||||
min-width: 0; background: var(--b2-surface); border: 1px solid var(--b2-border);
|
||||
border-radius: 14px; padding: 12px; min-height: 0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.col-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 11px; }
|
||||
.col-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.col-label { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .12em; color: var(--b2-muted); }
|
||||
.col-count { margin-left: auto; min-width: 26px; text-align: center; border-radius: 999px;
|
||||
padding: 1px 9px; background: rgba(255,255,255,.08); color: var(--b2-muted); font: 12px/1.5 var(--mono); }
|
||||
.column[data-column="open"] .col-dot { background: var(--open); }
|
||||
.column[data-column="in_progress"] .col-dot { background: var(--in_progress); }
|
||||
.column[data-column="review"] .col-dot { background: var(--review); }
|
||||
.column[data-column="done"] .col-dot { background: var(--done); }
|
||||
.column[data-column="cancelled"] .col-dot { background: var(--cancelled); }
|
||||
.cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-auto-rows: max-content; /* auto-Tracks fielen auf min-height zurück → Overlap */
|
||||
gap: 10px; min-width: 0; min-height: 0; overflow-y: auto;
|
||||
overscroll-behavior: contain; padding: 2px 4px 2px 2px;
|
||||
align-items: start; scrollbar-gutter: stable;
|
||||
}
|
||||
.card {
|
||||
min-width: 0; max-width: 100%; overflow: hidden;
|
||||
background: var(--b2-raised); border: 1px solid var(--b2-border);
|
||||
border-left: 3px solid var(--accent); border-radius: 11px;
|
||||
padding: 10px 12px; min-height: 142px;
|
||||
display: flex; flex-direction: column; user-select: none;
|
||||
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease, min-height 200ms ease;
|
||||
}
|
||||
.card:hover { border-color: rgba(56,189,248,.4); background: rgba(255,255,255,.075); transform: translateY(-1px); box-shadow: 0 12px 28px rgba(2,6,18,.3); }
|
||||
.column[data-column="open"] .card { border-left-color: var(--open); }
|
||||
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
|
||||
.column[data-column="review"] .card { border-left-color: var(--review); }
|
||||
.column[data-column="done"] .card { border-left-color: var(--done); }
|
||||
.column[data-column="cancelled"] .card { border-left-color: var(--cancelled); }
|
||||
.card.b2-working { border-color: rgba(56,189,248,.35); box-shadow: 0 0 22px rgba(56,189,248,.08); }
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
|
||||
.id { color: var(--b2-accent); font: 11px/1.3 var(--mono); white-space: nowrap; opacity: .85; }
|
||||
.title { margin: 0 0 8px; font-weight: 650; font-size: 13.5px; line-height: 1.35;
|
||||
overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.meta-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.pill, .badge {
|
||||
display: inline-flex; align-items: center; gap: 5px; min-height: 22px;
|
||||
border-radius: 999px; padding: 2px 8px;
|
||||
border: 1px solid rgba(148,163,184,.25); background: rgba(148,163,184,.08);
|
||||
color: var(--b2-muted); font: 11px/1.2 var(--mono); white-space: nowrap;
|
||||
}
|
||||
.status-pill { color: var(--text); }
|
||||
.status-open { border-color: rgba(139,148,158,.38); color: var(--open); }
|
||||
.status-in_progress { border-color: rgba(56,189,248,.42); color: var(--in_progress); background: rgba(56,189,248,.10); }
|
||||
.status-review { border-color: rgba(251,191,36,.42); color: var(--review); background: rgba(251,191,36,.10); }
|
||||
.status-done { border-color: rgba(52,211,153,.40); color: var(--done); background: rgba(52,211,153,.10); }
|
||||
.status-cancelled { border-color: rgba(110,118,129,.45); color: var(--cancelled); }
|
||||
.project-tag { color: var(--accent); border-color: rgba(56,189,248,.30); background: rgba(56,189,248,.08); }
|
||||
.timer-badge { color: var(--text); background: rgba(255,255,255,.05); }
|
||||
.avatar {
|
||||
display: inline-flex; align-items: center; gap: 6px; min-height: 24px; max-width: 100%;
|
||||
padding: 2px 8px 2px 3px; border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.05);
|
||||
color: var(--text); font: 11px/1.2 var(--mono);
|
||||
}
|
||||
.avatar-core { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center;
|
||||
background: var(--agent-color); color: #0e1226; font-size: 9px; font-weight: 800; }
|
||||
.avatar.architect { box-shadow: 0 0 0 2px rgba(217,119,87,.24); }
|
||||
.avatar-label { overflow: hidden; text-overflow: ellipsis; max-width: 120px; }
|
||||
.reviewer-wrap { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 100%; }
|
||||
.reviewer-label { color: var(--review); font: 10px/1.2 var(--mono); white-space: nowrap; }
|
||||
.empty { color: var(--b2-muted); font-size: 12px; padding: 4px 2px; }
|
||||
|
||||
/* ── Progress ring (in-progress cards) ────────────────────────────── */
|
||||
.b2-ring { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; }
|
||||
.b2-ring svg { display: block; transform: rotate(-90deg); }
|
||||
.b2-ring-track { fill: none; stroke: rgba(255,255,255,.09); }
|
||||
.b2-ring-fill { fill: none; stroke: var(--in_progress); stroke-linecap: round;
|
||||
transition: stroke-dashoffset 900ms cubic-bezier(.2,.7,.2,1), stroke 300ms ease; }
|
||||
.b2-ring.b2-over .b2-ring-fill { stroke: var(--b2-amber); }
|
||||
.b2-ring.b2-ring-review .b2-ring-fill { stroke: var(--review); }
|
||||
.b2-ring-time { font: 11px/1 var(--mono); color: var(--b2-muted); }
|
||||
.b2-ring.b2-over .b2-ring-time { color: var(--b2-amber); }
|
||||
|
||||
/* ── Card delete affordance ───────────────────────────────────────── */
|
||||
.card-top-right { display: flex; align-items: center; gap: 6px; }
|
||||
.card-del { width: 24px; height: 24px; flex: 0 0 auto; display: inline-grid; place-items: center;
|
||||
border-radius: 7px; border: 1px solid transparent; background: transparent; color: var(--b2-muted);
|
||||
line-height: 1; cursor: pointer; opacity: 0; transition: opacity 140ms, color 140ms, border-color 140ms, background 140ms; }
|
||||
.card:hover .card-del, .card:focus-within .card-del { opacity: 1; }
|
||||
.card-del:hover { color: #fca5a5; border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.12); }
|
||||
|
||||
/* ── In-card live console (ported) ────────────────────────────────── */
|
||||
.card-console-wrap { margin-top: 9px; }
|
||||
.card:has(.card-console:not([hidden])) { min-height: 230px; }
|
||||
.card-console-toggle { display: inline-flex; align-items: center; gap: 6px;
|
||||
background: transparent; border: 1px solid var(--b2-border); color: var(--b2-muted);
|
||||
font: 700 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em;
|
||||
padding: 4px 9px; border-radius: 6px; cursor: pointer; transition: color 140ms, border-color 140ms; }
|
||||
.card-console-toggle:hover { color: var(--text); border-color: var(--b2-muted); }
|
||||
.card-console-toggle[aria-expanded="true"] { color: var(--in_progress); border-color: rgba(56,189,248,.4); }
|
||||
.card-console-toggle .cc-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--in_progress);
|
||||
box-shadow: 0 0 0 3px rgba(56,189,248,.16); animation: ccPulse 1.6s ease-in-out infinite; }
|
||||
.card-console-toggle .cc-chevron { font-size: 9px; opacity: .75; }
|
||||
@keyframes ccPulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
.card-console { margin-top: 7px; }
|
||||
.card-console[hidden] { display: none; }
|
||||
.card-console-body { max-height: 180px; overflow-y: auto; background: rgba(0,0,0,.28);
|
||||
border: 1px solid var(--b2-border); border-radius: 8px; padding: 8px 10px;
|
||||
font: 11px/1.55 var(--mono); color: #cbd5e1; }
|
||||
.cc-line { display: flex; gap: 8px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 1px 0; }
|
||||
.cc-line .cc-ts { color: var(--b2-muted); opacity: .8; flex: 0 0 auto; }
|
||||
.cc-line .cc-agent { color: var(--accent); font-weight: 600; flex: 0 0 auto; }
|
||||
.cc-line.level-error .cc-text { color: #fca5a5; }
|
||||
.cc-line.level-warn .cc-text { color: #fcd34d; }
|
||||
.cc-line.level-bridge .cc-text { color: #c4b5fd; }
|
||||
.cc-empty { color: var(--b2-muted); font-style: italic; }
|
||||
|
||||
/* ── Drag & drop (ported) ─────────────────────────────────────────── */
|
||||
.card { touch-action: none; }
|
||||
.card.dragging { opacity: .5; cursor: grabbing; transform: scale(.98) rotate(-1deg); }
|
||||
.card.flash { animation: cardFlash 900ms ease; }
|
||||
@keyframes cardFlash {
|
||||
0% { border-color: var(--green); box-shadow: 0 0 0 2px rgba(52,211,153,.4); }
|
||||
100% { border-color: var(--b2-border); box-shadow: none; }
|
||||
}
|
||||
/* Enter animation only on genuinely new card nodes — NOT every rerender. */
|
||||
.card-new { animation: cardEnter 240ms cubic-bezier(.2,.7,.2,1); }
|
||||
@keyframes cardEnter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.column.drop-active { border-color: var(--accent); background: rgba(56,189,248,.07); box-shadow: 0 0 0 1px rgba(56,189,248,.4) inset; }
|
||||
.column.drop-active .col-head { color: var(--accent); }
|
||||
.column .cards.drop-hover { outline: 1px dashed rgba(56,189,248,.5); outline-offset: 3px; border-radius: 6px; }
|
||||
.card.drop-active { border-color: var(--green); background: rgba(52,211,153,.09); box-shadow: 0 0 0 2px rgba(52,211,153,.45); transform: translateY(-1px); }
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────── */
|
||||
.b2-side { display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
||||
position: sticky; top: 84px; max-height: calc(100vh - 102px); overflow-y: auto; }
|
||||
.b2-panel { padding: 13px 15px; }
|
||||
.b2-panel h6 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--b2-muted); }
|
||||
.b2-subhead { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.b2-subhead .seg { margin-left: auto; }
|
||||
#budgetRows { margin-top: 10px; }
|
||||
.b2-chart-tip { color: var(--b2-muted); font-size: 11px; text-align: right; margin-top: 5px; font-family: var(--mono); }
|
||||
|
||||
/* Budget internals (ported v1: seg, reset, donuts, legend, agent bars) */
|
||||
.budget-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.budget-head .est-note { color: var(--b2-muted); font-size: 11px; }
|
||||
.budget-head .total { margin-left: auto; font: 700 14px/1 var(--mono); color: var(--text); }
|
||||
.budget-head .total small { color: var(--b2-muted); font-weight: 400; }
|
||||
.seg { display: inline-flex; align-items: center; gap: 2px; padding: 3px;
|
||||
border: 1px solid var(--b2-border); border-radius: 8px; background: rgba(0,0,0,.2); }
|
||||
.seg button, .reset-btn { min-height: 26px; border: 0; border-radius: 6px; color: var(--b2-muted);
|
||||
background: transparent; font: 700 11px/1 var(--mono); padding: 0 9px; cursor: pointer;
|
||||
transition: color 160ms ease, background 160ms ease; }
|
||||
.seg button.active { color: var(--text); background: rgba(255,255,255,.1); }
|
||||
.reset-btn { border: 1px solid rgba(148,163,184,.22); }
|
||||
.reset-btn:hover, .seg button:hover { color: var(--text); background: rgba(148,163,184,.12); }
|
||||
.donut-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
|
||||
.donut-card { min-width: 0; padding: 10px; border: 1px solid var(--b2-border); border-radius: 10px; background: rgba(0,0,0,.18); }
|
||||
.donut-title { margin: 0 0 6px; color: var(--b2-muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.donut-tabs { display: flex; justify-content: flex-end; gap: 3px; margin: -2px 0 4px; }
|
||||
.donut-tabs button { min-height: 26px; border: 1px solid var(--b2-border); border-radius: 6px;
|
||||
background: transparent; color: var(--b2-muted); font: 700 10.5px/1 var(--mono); padding: 0 8px; }
|
||||
.donut-tabs button.active { background: rgba(255,255,255,.1); color: var(--text); }
|
||||
.half-donut { position: relative; width: min(220px, 100%); aspect-ratio: 220 / 132; display: grid; place-items: center; margin: 0 auto; }
|
||||
.half-donut svg { grid-area: 1 / 1; width: 100%; height: 100%; overflow: visible; }
|
||||
.donut-segment { animation: donutDraw 1100ms cubic-bezier(.2,.7,.2,1) both; }
|
||||
@keyframes donutDraw { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
|
||||
.donut-value { fill: var(--text); font: 800 28px var(--mono); }
|
||||
.donut-label { fill: var(--b2-muted); font: 10px var(--mono); }
|
||||
.legend { display: grid; gap: 6px; margin-top: 6px; }
|
||||
.legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 7px; font: 11px/1.25 var(--mono); color: var(--b2-muted); }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 16%, transparent); }
|
||||
.legend-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-bars { margin-top: 12px; }
|
||||
.agent-bars h3 { margin: 0 0 8px; font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--b2-muted); }
|
||||
.agent-bar-row { display: grid; grid-template-columns: minmax(96px, 1fr) 1.35fr; gap: 10px; align-items: center;
|
||||
padding: 8px 0; border-top: 1px solid var(--b2-border); }
|
||||
.agent-bar-row:first-of-type { border-top: 0; }
|
||||
.budget-agent { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.budget-agent .ba-core { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center;
|
||||
background: var(--agent-color); color: #0e1226; font: 800 9px/1 var(--mono); flex: 0 0 auto; }
|
||||
.budget-agent .ba-name { display: block; font-weight: 650; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.budget-agent .ba-model { display: block; color: var(--b2-muted); font: 10px/1.2 var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.agent-bar-cell { min-width: 0; }
|
||||
.agent-bar-track { height: 7px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; }
|
||||
.agent-bar-track > span { display: block; height: 100%;
|
||||
background: linear-gradient(90deg, var(--bar-color), color-mix(in srgb, var(--bar-color) 62%, #fff));
|
||||
width: 0; transition: width 900ms cubic-bezier(.2,.7,.2,1); }
|
||||
.agent-bar-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; color: var(--b2-muted); font: 10.5px/1.25 var(--mono); }
|
||||
.agent-bar-meta .tok { color: var(--text); font-weight: 650; }
|
||||
.budget-empty { color: var(--b2-muted); font-size: 12px; padding: 6px 0; }
|
||||
|
||||
/* ── Live feed ────────────────────────────────────────────────────── */
|
||||
.b2-feed { margin-top: 8px; font-size: 12px; color: #aab6d4; font-family: var(--mono); }
|
||||
.b2-feed > div { padding: 4px 0; border-bottom: 1px dashed rgba(255,255,255,.06); display: flex; gap: 7px; align-items: baseline; }
|
||||
.b2-feed > div:last-child { border-bottom: 0; }
|
||||
.b2-feed .pulse { color: var(--b2-green); animation: b2-blink 1.6s infinite; }
|
||||
.b2-feed .f-time { color: var(--b2-dim); font-size: 10px; margin-left: auto; flex: 0 0 auto; }
|
||||
|
||||
/* ── Modals (ported v1) ───────────────────────────────────────────── */
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start;
|
||||
justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62);
|
||||
backdrop-filter: blur(3px); animation: modalFade 140ms ease; }
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.modal { width: min(520px, 100%); background: #10142a; border: 1px solid var(--b2-border);
|
||||
border-radius: 14px; box-shadow: 0 24px 64px rgba(0,0,0,.5); animation: modalRise 180ms cubic-bezier(.2,.7,.2,1); }
|
||||
@keyframes modalRise { from { transform: translateY(12px); opacity: .4; } to { transform: none; opacity: 1; } }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; }
|
||||
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; }
|
||||
.modal-x { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--b2-border);
|
||||
background: var(--b2-raised); color: var(--b2-muted); font-size: 20px; line-height: 1; cursor: pointer;
|
||||
transition: color 140ms, border-color 140ms; }
|
||||
.modal-x:hover { color: var(--text); border-color: var(--b2-muted); }
|
||||
#tmForm { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-field-inline { flex-direction: row; align-items: center; gap: 12px; }
|
||||
.modal-field-inline .modal-label { margin: 0; }
|
||||
.modal-label { color: var(--b2-muted); font: 600 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.modal-field input, .modal-field textarea, .modal-field select {
|
||||
background: rgba(0,0,0,.22); border: 1px solid var(--b2-border); border-radius: 8px;
|
||||
color: var(--text); font: 14px/1.4 var(--sans); padding: 9px 11px; }
|
||||
.modal-field textarea { resize: vertical; min-height: 84px; }
|
||||
.modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.modal-note { margin: 0; color: var(--b2-muted); font-size: 11.5px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
.modal-cancel, .modal-create { min-height: 38px; padding: 0 16px; border-radius: 8px;
|
||||
font: 600 13px/1 var(--sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, border-color 160ms ease; }
|
||||
.modal-cancel { border: 1px solid var(--b2-border); background: var(--b2-raised); color: var(--b2-muted); }
|
||||
.modal-cancel:hover { color: var(--text); border-color: var(--b2-muted); }
|
||||
.modal-create { border: 1px solid var(--green); background: rgba(52,211,153,.16); color: #d1fadf; }
|
||||
.modal-create:hover { background: rgba(52,211,153,.26); }
|
||||
.modal-create:active, .modal-cancel:active { transform: translateY(1px); }
|
||||
.confirm-modal { width: min(420px, 100%); }
|
||||
.confirm-body { padding: 2px 18px 2px; }
|
||||
.confirm-body p { margin: 0; color: var(--b2-muted); font-size: 13.5px; line-height: 1.5; }
|
||||
.confirm-body b { color: var(--text); }
|
||||
.confirm-modal .modal-actions { padding: 14px 18px 18px; margin-top: 0; }
|
||||
.modal-danger { min-height: 38px; padding: 0 16px; border-radius: 8px;
|
||||
border: 1px solid var(--danger); background: rgba(248,81,73,.16); color: #ffd7d3;
|
||||
font: 600 13px/1 var(--sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease; }
|
||||
.modal-danger:hover { background: rgba(248,81,73,.26); }
|
||||
.modal-danger:active { transform: translateY(1px); }
|
||||
.detail-modal { width: min(760px, 100%); max-height: min(78vh, 760px); display: flex; flex-direction: column; }
|
||||
.detail-body { min-height: 0; overflow-y: auto; padding: 4px 18px 18px; display: grid; gap: 12px; }
|
||||
.detail-topline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.detail-title { margin: 0; font-size: 19px; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.detail-section { border-top: 1px solid var(--b2-border); padding-top: 12px; display: grid; gap: 8px; }
|
||||
.detail-section h3 { margin: 0; color: var(--b2-muted); font: 700 11px/1.2 var(--mono); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.detail-description { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); font: 12.5px/1.55 var(--mono); }
|
||||
.detail-activity { display: grid; gap: 0; }
|
||||
.detail-activity-row { display: grid; grid-template-columns: 78px 82px minmax(0, 1fr) auto; gap: 8px; padding: 8px 0; border-top: 1px solid rgba(255,255,255,.06); align-items: start; }
|
||||
.detail-activity-row:first-child { border-top: 0; padding-top: 0; }
|
||||
.detail-when, .detail-actor { color: var(--b2-muted); font: 11px/1.45 var(--mono); }
|
||||
.detail-kind { color: var(--accent); font: 11px/1.45 var(--mono); }
|
||||
.detail-summary { min-width: 0; overflow-wrap: anywhere; }
|
||||
.detail-error { color: #fca5a5; }
|
||||
|
||||
/* ── Toasts (ported v1) ───────────────────────────────────────────── */
|
||||
.toasts { position: fixed; right: 18px; bottom: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
|
||||
.toast { display: flex; align-items: center; gap: 8px; background: #151a33; border: 1px solid var(--b2-border);
|
||||
border-left: 3px solid var(--green); border-radius: 10px; padding: 9px 13px; color: var(--text);
|
||||
font: 13px/1.3 var(--sans); box-shadow: 0 8px 28px rgba(0,0,0,.42);
|
||||
animation: toastIn 220ms cubic-bezier(.2,.7,.2,1); max-width: 340px; }
|
||||
.toast.out { animation: toastOut 240ms ease forwards; }
|
||||
.toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(52,211,153,.18); flex: 0 0 auto; }
|
||||
.toast.err { border-left-color: var(--danger); }
|
||||
.toast.err .dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248,81,73,.18); }
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }
|
||||
|
||||
/* ── Splash ───────────────────────────────────────────────────────── */
|
||||
.b2-splash { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(140% 120% at 15% -10%, #1c2547 0%, #0e1226 50%, #090c18 100%);
|
||||
transition: opacity .4s ease; }
|
||||
.b2-splash-hide { opacity: 0; pointer-events: none; }
|
||||
.b2-splash-inner { text-align: center; animation: b2-rise .5s both; }
|
||||
.b2-splash-logo { animation: b2-ping 1.6s ease-in-out infinite; border-radius: 18px; }
|
||||
.b2-splash-word { margin-top: 14px; font-size: 22px; font-weight: 700; letter-spacing: .02em; color: #eef1f8; }
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────── */
|
||||
@media (max-width: 1120px) {
|
||||
.b2-body { grid-template-columns: 1fr; }
|
||||
.b2-side { position: static; max-height: none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
body { padding-left: 14px; padding-right: 14px; }
|
||||
.app-header { padding-left: 14px; padding-right: 14px; }
|
||||
.b2-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.board { grid-template-columns: repeat(2, 1fr); }
|
||||
.app-header .b2-proj { max-width: 46vw; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.b2-kpis { grid-template-columns: 1fr; }
|
||||
.board { grid-template-columns: 1fr; }
|
||||
.detail-activity-row { grid-template-columns: 1fr; gap: 3px; }
|
||||
.agent-bar-row { grid-template-columns: 1fr; gap: 6px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.b2, .b2 *, body, body * { animation: none !important; transition: none !important; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
335
src/server/board/v1Budget.ts
Normal file
335
src/server/board/v1Budget.ts
Normal file
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Verbatim port of the v1 budget-panel client JS (old src/server/board.ts,
|
||||
* lines 1466–1750). Kept byte-identical on purpose: session/total baseline
|
||||
* logic, donut signature throttling and the reset handler are battle-tested.
|
||||
*
|
||||
* Depends on page-scope helpers provided by the columns/data-layer port:
|
||||
* esc, getJSON, hashColor, agentSpec, toast. Mount points expected in the DOM:
|
||||
* #budgetRows, #budgetTotal, #budgetReset, [data-budget-mode].
|
||||
*/
|
||||
export const v1BudgetJs = `
|
||||
// ── Roster ──────────────────────────────────────────────────────────────
|
||||
// AGENTS backs the budget panel + resolves a title's "name:" prefix to a
|
||||
// known agent when the architect drags a card into In Progress.
|
||||
var AGENTS = [];
|
||||
var BUDGET = null;
|
||||
var budgetMode = localStorage.getItem('agenthub-budget-mode') || 'session';
|
||||
var donutMetric = localStorage.getItem('agenthub-donut-metric') || 'tokens';
|
||||
var lastDonutSignature = '';
|
||||
var lastDonutAt = 0;
|
||||
var DONUT_REFRESH_MS = 180000;
|
||||
|
||||
async function loadAgents() {
|
||||
try {
|
||||
AGENTS = await getJSON('/agents');
|
||||
} catch (_) { AGENTS = []; }
|
||||
}
|
||||
|
||||
// ── Budget panel ────────────────────────────────────────────────────────
|
||||
function fmtTokens(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||
return String(Math.round(n));
|
||||
}
|
||||
function fmtEur(n) { return '\\u20ac' + (Number(n) || 0).toFixed(2); }
|
||||
function maxTok(list) { return list.reduce(function(m, a) { return Math.max(m, a.tokens || 0); }, 0); }
|
||||
function cloneAgent(a) {
|
||||
return {
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
model: a.model,
|
||||
kind: a.kind,
|
||||
tokens: Number(a.tokens) || 0,
|
||||
realTokens: Number(a.realTokens) || 0,
|
||||
estimatedTokens: Number(a.estimatedTokens) || 0,
|
||||
estimated: !!a.estimated,
|
||||
costEur: Number(a.costEur) || 0,
|
||||
taskCount: Number(a.taskCount) || 0
|
||||
};
|
||||
}
|
||||
function currentSnapshot(rep) {
|
||||
var byAgent = {};
|
||||
(rep && rep.agents ? rep.agents : []).forEach(function(a) {
|
||||
byAgent[a.name] = { tokens: Number(a.tokens) || 0, costEur: Number(a.costEur) || 0 };
|
||||
});
|
||||
return { createdAt: Date.now(), byAgent: byAgent };
|
||||
}
|
||||
function readBaseline() {
|
||||
try { return JSON.parse(localStorage.getItem('agenthub-budget-baseline') || 'null'); } catch (_) { return null; }
|
||||
}
|
||||
function writeBaseline(rep) {
|
||||
localStorage.setItem('agenthub-budget-baseline', JSON.stringify(currentSnapshot(rep)));
|
||||
}
|
||||
function applyBudgetMode(rep) {
|
||||
var agents = (rep && rep.agents ? rep.agents : []).map(cloneAgent);
|
||||
if (budgetMode === 'session') {
|
||||
var base = readBaseline();
|
||||
if (!base) { writeBaseline(rep); base = readBaseline(); }
|
||||
agents.forEach(function(a) {
|
||||
var b = base && base.byAgent ? base.byAgent[a.name] : null;
|
||||
var baseTokens = b ? Number(b.tokens) || 0 : 0;
|
||||
var baseCost = b ? Number(b.costEur) || 0 : 0;
|
||||
a.tokens = Math.max(0, a.tokens - baseTokens);
|
||||
a.costEur = Math.max(0, a.costEur - baseCost);
|
||||
a.realTokens = Math.min(a.realTokens, a.tokens);
|
||||
a.estimatedTokens = Math.max(0, a.tokens - a.realTokens);
|
||||
a.estimated = a.estimated && a.estimatedTokens > 0;
|
||||
});
|
||||
}
|
||||
var totals = {
|
||||
tokens: agents.reduce(function(s, a) { return s + (a.tokens || 0); }, 0),
|
||||
costEur: agents.reduce(function(s, a) { return s + (a.costEur || 0); }, 0),
|
||||
estimated: agents.some(function(a) { return a.estimated; })
|
||||
};
|
||||
return { agents: agents, totals: totals };
|
||||
}
|
||||
function companySpec(kind) {
|
||||
var key = String(kind || '').toLowerCase();
|
||||
var map = {
|
||||
anthropic: { name: 'Anthropic', color: '#D97757' },
|
||||
openai: { name: 'OpenAI', color: '#10A37F' },
|
||||
moonshot: { name: 'Moonshot', color: '#7C3AED' }
|
||||
};
|
||||
return map[key] || { name: key ? key.charAt(0).toUpperCase() + key.slice(1) : 'Unknown', color: hashColor(key || 'unknown') };
|
||||
}
|
||||
function donutPolar(cx, cy, r, angleDeg) {
|
||||
var rad = angleDeg * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
function donutArc(cx, cy, r, startDeg, endDeg) {
|
||||
var start = donutPolar(cx, cy, r, startDeg);
|
||||
var end = donutPolar(cx, cy, r, endDeg);
|
||||
var largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return 'M ' + start.x.toFixed(2) + ' ' + start.y.toFixed(2) + ' A ' + r + ' ' + r + ' 0 ' + largeArc + ' 1 ' + end.x.toFixed(2) + ' ' + end.y.toFixed(2);
|
||||
}
|
||||
function aggregateByCompany(agents, field) {
|
||||
var byKind = {};
|
||||
(agents || []).forEach(function(a) {
|
||||
var kind = String(a.kind || '').toLowerCase();
|
||||
if (!kind || !(a.tokens > 0)) return;
|
||||
var spec = companySpec(kind);
|
||||
if (!byKind[kind]) byKind[kind] = { kind: kind, name: spec.name, color: spec.color, value: 0 };
|
||||
byKind[kind].value += Number(a[field]) || 0;
|
||||
});
|
||||
return Object.keys(byKind).map(function(k) { return byKind[k]; }).sort(function(a, b) { return b.value - a.value; });
|
||||
}
|
||||
// The donut card hosts three tabs: Token | Kosten | Verlauf (throughput chart).
|
||||
function donutTabsHtml() {
|
||||
return '<div class="donut-tabs">' +
|
||||
'<button type="button" data-donut-tab="tokens" class="' + (donutMetric === 'tokens' ? 'active' : '') + '">Token</button>' +
|
||||
'<button type="button" data-donut-tab="cost" class="' + (donutMetric === 'cost' ? 'active' : '') + '">Kosten</button>' +
|
||||
'<button type="button" data-donut-tab="verlauf" class="' + (donutMetric === 'verlauf' ? 'active' : '') + '">Verlauf</button>' +
|
||||
'</div>';
|
||||
}
|
||||
function donutCardShell(title, bodyHtml) {
|
||||
return '<div class="donut-card"><h3 class="donut-title">' + esc(title) + '</h3>' + donutTabsHtml() + bodyHtml + '</div>';
|
||||
}
|
||||
function halfDonut(title, rows, centerValue, centerLabel, formatValue) {
|
||||
var total = rows.reduce(function(s, x) { return s + (x.value || 0); }, 0);
|
||||
var safeTotal = Math.max(1, total);
|
||||
var cx = 110, cy = 122, r = 90, stroke = 18;
|
||||
var angle = 180;
|
||||
var paths = '<path d="' + donutArc(cx, cy, r, 180, 360) + '" stroke="rgba(255,255,255,.09)" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" />';
|
||||
rows.forEach(function(row, idx) {
|
||||
var start = angle;
|
||||
var end = angle + 180 * ((row.value || 0) / safeTotal);
|
||||
angle = end;
|
||||
if (end <= start + 0.4) return;
|
||||
paths += '<path class="donut-segment" d="' + donutArc(cx, cy, r, start, end) + '" stroke="' + esc(row.color) + '" stroke-width="' + stroke + '" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" style="animation-delay:' + (idx * 80) + 'ms" />';
|
||||
});
|
||||
if (!rows.length) paths += '<circle cx="' + cx + '" cy="' + (cy - r + stroke / 2) + '" r="3" fill="#94A3B8" />';
|
||||
var legend = rows.length ? rows.map(function(row) {
|
||||
var pct = total > 0 ? Math.round((row.value / total) * 100) : 0;
|
||||
return '<div class="legend-row" style="color:' + esc(row.color) + '">' +
|
||||
'<span class="legend-dot"></span><span class="legend-name">' + esc(row.name) + '</span>' +
|
||||
'<span>' + esc(formatValue(row.value)) + ' · ' + pct + '%</span></div>';
|
||||
}).join('') : '<div class="empty">no provider data</div>';
|
||||
return '<div class="donut-card">' +
|
||||
'<h3 class="donut-title">' + esc(title) + '</h3>' +
|
||||
donutTabsHtml() +
|
||||
'<div class="half-donut">' +
|
||||
'<svg viewBox="0 0 220 132" role="img" aria-label="' + esc(title) + '">' + paths +
|
||||
'<text class="donut-value" x="110" y="102" text-anchor="middle" dominant-baseline="central" data-count-to="' + esc(centerValue) + '" data-count-format="' + (formatValue === fmtEur ? 'eur' : 'tokens') + '">0</text>' +
|
||||
'<text class="donut-label" x="110" y="120" text-anchor="middle" dominant-baseline="central">' + esc(centerLabel) + '</text>' +
|
||||
'</svg>' +
|
||||
'</div>' +
|
||||
'<div class="legend">' + legend + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
function animateCounts(root) {
|
||||
var nodes = (root || document).querySelectorAll('[data-count-to]');
|
||||
nodes.forEach(function(node) {
|
||||
var target = Number(node.getAttribute('data-count-to')) || 0;
|
||||
var format = node.getAttribute('data-count-format');
|
||||
var start = performance.now();
|
||||
function step(now) {
|
||||
var p = Math.min(1, (now - start) / 900);
|
||||
var eased = 1 - Math.pow(1 - p, 3);
|
||||
var val = target * eased;
|
||||
node.textContent = format === 'eur' ? fmtEur(val) : fmtTokens(val);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
function signatureFor(rows) {
|
||||
return rows.map(function(r) { return r.kind + ':' + Math.round((Number(r.value) || 0) * 100); }).join('|');
|
||||
}
|
||||
function shouldRenderDonut(nextSig, force) {
|
||||
if (force) return true;
|
||||
var now = Date.now();
|
||||
if (!lastDonutSignature || now - lastDonutAt > DONUT_REFRESH_MS) return true;
|
||||
if (nextSig === lastDonutSignature) return false;
|
||||
var prev = {};
|
||||
lastDonutSignature.split('|').forEach(function(part) {
|
||||
if (!part) return;
|
||||
var bits = part.split(':');
|
||||
prev[bits[0]] = Number(bits[1]) || 0;
|
||||
});
|
||||
var changed = false;
|
||||
nextSig.split('|').forEach(function(part) {
|
||||
if (!part) return;
|
||||
var bits = part.split(':');
|
||||
var oldVal = prev[bits[0]] || 0;
|
||||
var newVal = Number(bits[1]) || 0;
|
||||
if (Math.abs(newVal - oldVal) / Math.max(1, oldVal) > 0.10) changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
function renderDonut(active, force) {
|
||||
var wrap = document.getElementById('donutWrap');
|
||||
if (!wrap) return;
|
||||
// Verlauf tab: throughput area chart instead of the donut. Static
|
||||
// signature — the chart content itself updates via __b2RenderThroughput
|
||||
// on task refreshes, so the 3s budget poll must not rebuild the card.
|
||||
if (donutMetric === 'verlauf') {
|
||||
if (lastDonutSignature === 'verlauf' && !force) return;
|
||||
lastDonutSignature = 'verlauf';
|
||||
lastDonutAt = Date.now();
|
||||
wrap.innerHTML = donutCardShell('Verlauf · erledigte Tasks',
|
||||
'<div id="b2Throughput"></div><div class="b2-chart-tip" id="b2ThroughputTip"></div>');
|
||||
if (window.__b2RenderThroughput) {
|
||||
window.__b2RenderThroughput(typeof lastTasks !== 'undefined' ? lastTasks : []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var field = donutMetric === 'cost' ? 'costEur' : 'tokens';
|
||||
var companies = aggregateByCompany(active, field);
|
||||
var sig = donutMetric + ':' + signatureFor(companies);
|
||||
if (!shouldRenderDonut(sig, force)) return;
|
||||
lastDonutSignature = sig;
|
||||
lastDonutAt = Date.now();
|
||||
var center = active.reduce(function(s, a) { return s + (Number(a[field]) || 0); }, 0);
|
||||
wrap.innerHTML = halfDonut(donutMetric === 'cost' ? 'Cost by Company' : 'Tokens by Company', companies, center, donutMetric === 'cost' ? 'EUR' : 'tokens', donutMetric === 'cost' ? fmtEur : fmtTokens);
|
||||
animateCounts(wrap);
|
||||
}
|
||||
function agentBarHtml(a, mx) {
|
||||
var spec = agentSpec(a.name);
|
||||
var est = a.estimated ? '<span class="est">~ estimated</span>' : 'real';
|
||||
var pct = mx > 0 ? Math.round(((a.tokens || 0) / mx) * 1000) / 10 : 0;
|
||||
var company = companySpec(a.kind);
|
||||
return '<div class="agent-bar-row" data-agent-row="' + esc(a.name) + '">' +
|
||||
'<div class="budget-agent"><span class="ba-core" style="background:' + esc(spec.color) + '">' + esc(spec.initials) + '</span>' +
|
||||
'<span style="min-width:0"><span class="ba-name">' + esc(a.name) + '</span>' +
|
||||
'<span class="ba-model">' + esc(company.name + (a.model ? ' · ' + a.model : '')) + '</span></span></div>' +
|
||||
'<div class="agent-bar-cell"><div class="agent-bar-track" style="--bar-color:' + esc(company.color) + '"><span style="width:' + Math.min(100, pct) + '%"></span></div>' +
|
||||
'<div class="agent-bar-meta"><span class="tok">' + fmtTokens(a.tokens) + ' tok</span><span>' + est + ' · ' + fmtEur(a.costEur) + '</span></div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
function renderAgentBars(active) {
|
||||
var host = document.getElementById('agentBars');
|
||||
if (!host) return;
|
||||
var before = {};
|
||||
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
|
||||
before[el.getAttribute('data-agent-row')] = el.getBoundingClientRect();
|
||||
});
|
||||
var mx = maxTok(active) || 1;
|
||||
var sorted = active.slice().sort(function(a, b) { return (b.tokens || 0) - (a.tokens || 0) || a.name.localeCompare(b.name); });
|
||||
host.innerHTML = '<h3>Token-Verbrauch pro Agent</h3>' + sorted.map(function(a) { return agentBarHtml(a, mx); }).join('');
|
||||
host.querySelectorAll('[data-agent-row]').forEach(function(el) {
|
||||
var key = el.getAttribute('data-agent-row');
|
||||
var old = before[key];
|
||||
if (!old) return;
|
||||
var now = el.getBoundingClientRect();
|
||||
var dy = old.top - now.top;
|
||||
if (!dy) return;
|
||||
el.style.transform = 'translateY(' + dy + 'px)';
|
||||
requestAnimationFrame(function() { el.style.transform = ''; });
|
||||
});
|
||||
}
|
||||
function updateBudgetButtons() {
|
||||
document.querySelectorAll('[data-budget-mode]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-budget-mode') === budgetMode);
|
||||
});
|
||||
document.querySelectorAll('[data-donut-tab]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-donut-tab') === donutMetric);
|
||||
});
|
||||
}
|
||||
function renderBudget(rep, opts) {
|
||||
opts = opts || {};
|
||||
BUDGET = rep;
|
||||
var rows = document.getElementById('budgetRows');
|
||||
var total = document.getElementById('budgetTotal');
|
||||
if (!rows || !total) return;
|
||||
var scoped = applyBudgetMode(rep);
|
||||
var active = scoped.agents.filter(function(a) { return a.kind && a.tokens > 0; });
|
||||
updateBudgetButtons();
|
||||
// The card skeleton (and with it the Token/Kosten/Verlauf tabs) always
|
||||
// exists — even in the empty state the Verlauf tab must stay reachable.
|
||||
if (!document.getElementById('donutWrap') || !document.getElementById('agentBars')) {
|
||||
rows.innerHTML = '<div class="donut-grid"><div id="donutWrap"></div></div><div class="agent-bars" id="agentBars"></div>';
|
||||
}
|
||||
var bars = document.getElementById('agentBars');
|
||||
if (!active.length) {
|
||||
if (donutMetric === 'verlauf') {
|
||||
renderDonut(active, opts.forceDonut);
|
||||
} else {
|
||||
lastDonutSignature = '';
|
||||
document.getElementById('donutWrap').innerHTML = donutCardShell('Token Insights',
|
||||
'<div class="budget-empty">no real agent token data in this ' + (budgetMode === 'session' ? 'session' : 'total range') + '</div>');
|
||||
}
|
||||
if (bars) bars.innerHTML = '';
|
||||
total.textContent = '';
|
||||
return;
|
||||
}
|
||||
renderDonut(active, opts.forceDonut);
|
||||
// Agent bars belong to the token insights; hidden on the Verlauf tab.
|
||||
if (donutMetric === 'verlauf') { if (bars) bars.innerHTML = ''; }
|
||||
else renderAgentBars(active);
|
||||
total.innerHTML = (scoped.totals.estimated ? '~' : '') + fmtTokens(scoped.totals.tokens) +
|
||||
' tok <small>\\u00b7 \\u2248 ' + fmtEur(scoped.totals.costEur) + '</small>';
|
||||
}
|
||||
async function refreshBudget() {
|
||||
try { renderBudget(await getJSON('/budget')); } catch (_) {}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var modeBtn = e.target.closest && e.target.closest('[data-budget-mode]');
|
||||
if (modeBtn) {
|
||||
budgetMode = modeBtn.getAttribute('data-budget-mode') || 'session';
|
||||
localStorage.setItem('agenthub-budget-mode', budgetMode);
|
||||
lastDonutSignature = '';
|
||||
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
|
||||
return;
|
||||
}
|
||||
var tabBtn = e.target.closest && e.target.closest('[data-donut-tab]');
|
||||
if (tabBtn) {
|
||||
donutMetric = tabBtn.getAttribute('data-donut-tab') || 'tokens';
|
||||
localStorage.setItem('agenthub-donut-metric', donutMetric);
|
||||
if (BUDGET) renderBudget(BUDGET, { forceDonut: true });
|
||||
return;
|
||||
}
|
||||
var reset = e.target.closest && e.target.closest('#budgetReset');
|
||||
if (reset) {
|
||||
if (BUDGET) {
|
||||
writeBaseline(BUDGET);
|
||||
budgetMode = 'session';
|
||||
localStorage.setItem('agenthub-budget-mode', budgetMode);
|
||||
lastDonutSignature = '';
|
||||
renderBudget(BUDGET, { forceDonut: true });
|
||||
toast('Token session reset');
|
||||
}
|
||||
}
|
||||
});
|
||||
`;
|
||||
135
src/server/board/viewmodel.ts
Normal file
135
src/server/board/viewmodel.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Pure, dependency-free helpers for the board v2 UI.
|
||||
* These run BOTH in vitest (server-side) and in the browser — board/index.ts
|
||||
* injects them into the inline <script> via Function.prototype.toString().
|
||||
* Therefore: no imports, no closures over module state, ES2019 syntax only.
|
||||
*/
|
||||
|
||||
export interface KpiTask {
|
||||
id: string;
|
||||
status?: string;
|
||||
assignedTo?: string;
|
||||
reviewer?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
claimedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentChip {
|
||||
name: string;
|
||||
minutes: number | null;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 3600 * 1000;
|
||||
|
||||
/** Cap a chip list at `max` visible entries, reporting how many were hidden. */
|
||||
export function capChips<T>(chips: T[], max: number): { visible: T[]; hidden: number } {
|
||||
const visible = chips.slice(0, Math.max(0, max));
|
||||
return { visible, hidden: chips.length - visible.length };
|
||||
}
|
||||
|
||||
/** Done-card model: share of non-cancelled tasks done + done in the last 7 days. */
|
||||
export function doneStats(
|
||||
tasks: KpiTask[],
|
||||
now?: number,
|
||||
): { done: number; total: number; pct: number; doneThisWeek: number } {
|
||||
const t0 = now ?? Date.now();
|
||||
const relevant = tasks.filter((t) => t.status !== 'cancelled');
|
||||
const doneTasks = relevant.filter((t) => t.status === 'done');
|
||||
const weekAgo = t0 - 7 * DAY_MS;
|
||||
const doneThisWeek = doneTasks.filter(
|
||||
(t) => t.updatedAt && new Date(t.updatedAt).getTime() >= weekAgo,
|
||||
).length;
|
||||
const pct = relevant.length === 0 ? 0 : Math.round((doneTasks.length / relevant.length) * 100);
|
||||
return { done: doneTasks.length, total: relevant.length, pct, doneThisWeek };
|
||||
}
|
||||
|
||||
/** Local midnight for a timestamp (exported: co-injected into the browser page). */
|
||||
export function dayStart(ts: number): number {
|
||||
const d = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backlog (open) count for each of the last `days` days, oldest first.
|
||||
* Approximation: a task counts as backlog on day D when it existed by end of D
|
||||
* and was not yet moved out of 'open' (non-open tasks use updatedAt as the
|
||||
* transition timestamp; still-open tasks are backlog on every day since creation).
|
||||
*/
|
||||
export function backlogSeries(tasks: KpiTask[], days: number, now?: number): number[] {
|
||||
const today = dayStart(now ?? Date.now());
|
||||
const out: number[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const endOfDay = today - i * DAY_MS + DAY_MS - 1;
|
||||
let count = 0;
|
||||
for (const t of tasks) {
|
||||
const created = t.createdAt ? new Date(t.createdAt).getTime() : 0;
|
||||
if (created > endOfDay) continue;
|
||||
if (t.status === 'open') {
|
||||
count++;
|
||||
} else if (t.status !== 'cancelled') {
|
||||
const left = t.updatedAt ? new Date(t.updatedAt).getTime() : created;
|
||||
if (left > endOfDay) count++; // was still open on that day
|
||||
}
|
||||
}
|
||||
out.push(count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Done-per-day counts for the last `days` days, oldest first. */
|
||||
export function throughputSeries(tasks: KpiTask[], days: number, now?: number): number[] {
|
||||
const today = dayStart(now ?? Date.now());
|
||||
const out: number[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const start = today - i * DAY_MS;
|
||||
const end = start + DAY_MS - 1;
|
||||
out.push(
|
||||
tasks.filter(
|
||||
(t) =>
|
||||
t.status === 'done' &&
|
||||
t.updatedAt &&
|
||||
new Date(t.updatedAt).getTime() >= start &&
|
||||
new Date(t.updatedAt).getTime() <= end,
|
||||
).length,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build SVG line + area path data for a value series. Values are top-anchored (max = y 0). */
|
||||
export function areaPath(
|
||||
values: number[],
|
||||
w: number,
|
||||
h: number,
|
||||
): { line: string; area: string } {
|
||||
if (values.length === 0) return { line: '', area: '' };
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const span = max - min;
|
||||
const step = values.length > 1 ? w / (values.length - 1) : 0;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = Math.round(i * step * 100) / 100;
|
||||
const y = span === 0 ? h / 2 : Math.round(((max - v) / span) * h * 100) / 100;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
const line = `M${pts.join(' L')}`;
|
||||
return { line, area: `${line} L${w},${h} L0,${h} Z` };
|
||||
}
|
||||
|
||||
/** Chip model for one status lane (in_progress → assignedTo, review → reviewer). */
|
||||
export function laneChips(tasks: KpiTask[], status: string, now?: number): AgentChip[] {
|
||||
const t0 = now ?? Date.now();
|
||||
return tasks
|
||||
.filter((t) => t.status === status)
|
||||
.map((t) => {
|
||||
const name = status === 'review' ? t.reviewer ?? t.assignedTo : t.assignedTo;
|
||||
const since = t.claimedAt ?? t.updatedAt;
|
||||
return {
|
||||
name: name ?? '?',
|
||||
minutes: since ? Math.max(0, Math.round((t0 - new Date(since).getTime()) / 60000)) : null,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.name !== '?');
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent';
|
||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent';
|
||||
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
|
||||
|
||||
export interface AgentHubEvent {
|
||||
@ -12,6 +12,7 @@ export interface AgentHubEvent {
|
||||
status?: string;
|
||||
role?: string;
|
||||
assignedTo?: string;
|
||||
claimedBy?: string;
|
||||
reviewer?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
|
||||
{ dir: 'decisions', type: 'decision' },
|
||||
{ dir: 'memory', type: 'memory' },
|
||||
{ dir: 'messages', type: 'message' },
|
||||
{ dir: 'asks', type: 'ask' },
|
||||
];
|
||||
|
||||
// fs.watch can fire several events (rename + change) for a single write, and a
|
||||
@ -60,7 +61,7 @@ function toEvent(
|
||||
case 'task':
|
||||
return {
|
||||
stamp,
|
||||
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) },
|
||||
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), claimedBy: str(fm.claimedBy), reviewer: str(fm.reviewer) },
|
||||
};
|
||||
case 'handoff':
|
||||
return {
|
||||
@ -73,6 +74,8 @@ function toEvent(
|
||||
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
||||
case 'message':
|
||||
return { stamp, event: { type, action, id, title: `${str(fm.from)} → ${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
|
||||
case 'ask':
|
||||
return { stamp, event: { type, action, id, title: `${str(fm.from)} → ${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,16 @@ export function buildApp(cwd: string) {
|
||||
return app;
|
||||
}
|
||||
|
||||
function isTestRuntime(): boolean {
|
||||
const lifecycle = process.env.npm_lifecycle_event ?? '';
|
||||
return process.env.NODE_ENV === 'test'
|
||||
|| process.env.VITEST === 'true'
|
||||
|| process.env.VITEST_WORKER_ID !== undefined
|
||||
|| process.env.VITEST_POOL_ID !== undefined
|
||||
|| lifecycle === 'test'
|
||||
|| lifecycle.startsWith('test:');
|
||||
}
|
||||
|
||||
export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> {
|
||||
const app = buildApp(cwd);
|
||||
const port = options.port ?? 3377;
|
||||
@ -36,14 +46,19 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
||||
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
||||
console.log(`AgentHub: server listening on ${url}`);
|
||||
|
||||
const advertiseUrl = resolveAdvertiseUrl(host, actualPort);
|
||||
broadcaster = startDiscoveryBroadcaster(advertiseUrl);
|
||||
// Skip LAN advertisers under test: their background timers (mDNS re-advertise
|
||||
// poll + UDP discovery broadcaster) fire during unrelated tests, flood the
|
||||
// network with duplicate mDNS publishes ("Service name already in use") and
|
||||
// leak non-string logs into single-instance assertions. No production change.
|
||||
if (!isTestRuntime()) {
|
||||
broadcaster = startDiscoveryBroadcaster(() => resolveAdvertiseUrl(host, actualPort));
|
||||
|
||||
// Advertise a browsable LAN hostname over mDNS (best-effort).
|
||||
mdns = startMdnsAdvertise({ port: actualPort });
|
||||
if (mdns) {
|
||||
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
|
||||
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
|
||||
// Advertise a browsable LAN hostname over mDNS (best-effort).
|
||||
mdns = startMdnsAdvertise({ port: actualPort });
|
||||
if (mdns) {
|
||||
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
|
||||
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { app, url };
|
||||
@ -55,7 +70,7 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
||||
`Stop it, or start with a different --port.`,
|
||||
);
|
||||
} else {
|
||||
console.error(err);
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Bonjour, type Service } from 'bonjour-service';
|
||||
import { getLanIPv4 } from '../discovery.js';
|
||||
|
||||
/**
|
||||
* Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN
|
||||
@ -15,38 +16,80 @@ export interface MdnsHandle {
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined {
|
||||
export interface MdnsAdvertiseOptions {
|
||||
port: number;
|
||||
name?: string;
|
||||
pollMs?: number;
|
||||
getIp?: () => string | undefined;
|
||||
createBonjour?: () => Bonjour;
|
||||
}
|
||||
|
||||
export function startMdnsAdvertise(opts: MdnsAdvertiseOptions): MdnsHandle | undefined {
|
||||
const base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub';
|
||||
const hostname = `${base}.local`;
|
||||
try {
|
||||
const bonjour = new Bonjour();
|
||||
const service: Service = bonjour.publish({
|
||||
const pollMs = opts.pollMs ?? 15_000;
|
||||
const readIp = opts.getIp ?? getLanIPv4;
|
||||
const createBonjour = opts.createBonjour ?? (() => new Bonjour());
|
||||
let currentIp = readIp();
|
||||
let bonjour: Bonjour | undefined;
|
||||
let service: Service | undefined;
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const stopCurrent = () => {
|
||||
try {
|
||||
service?.stop?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
bonjour?.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
service = undefined;
|
||||
bonjour = undefined;
|
||||
};
|
||||
|
||||
const publish = () => {
|
||||
bonjour = createBonjour();
|
||||
service = bonjour.publish({
|
||||
name: 'AgentHub',
|
||||
type: 'http',
|
||||
port: opts.port,
|
||||
host: hostname,
|
||||
txt: { path: '/board' },
|
||||
txt: { path: '/board', address: currentIp ?? '' },
|
||||
});
|
||||
// Swallow responder errors — advertisement is optional infrastructure.
|
||||
service.on('error', () => {
|
||||
/* best-effort */
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
publish();
|
||||
pollTimer = setInterval(() => {
|
||||
const nextIp = readIp();
|
||||
const changed = nextIp !== currentIp;
|
||||
if (!changed && service) return;
|
||||
if (changed) {
|
||||
currentIp = nextIp;
|
||||
stopCurrent();
|
||||
}
|
||||
try {
|
||||
publish();
|
||||
} catch {
|
||||
// Keep polling; a later network state may be publishable.
|
||||
}
|
||||
}, pollMs);
|
||||
return {
|
||||
hostname,
|
||||
stop: () => {
|
||||
try {
|
||||
service.stop?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
bonjour.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
stopCurrent();
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
stopCurrent();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
221
src/server/messages.ts
Normal file
221
src/server/messages.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { loadConfig } from '../core/config.js';
|
||||
import { listMessages, getMessage } from '../core/services/messageService.js';
|
||||
import { getRoster } from '../core/services/rosterService.js';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
||||
import type { Message } from '../core/schema.js';
|
||||
|
||||
function ago(iso: string): string {
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return '';
|
||||
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function snippet(text: string, max = 88): string {
|
||||
const clean = (text ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (clean.length <= max) return clean;
|
||||
return `${clean.slice(0, max - 1)}...`;
|
||||
}
|
||||
|
||||
/** Stable, order-independent key for the conversation between two agents. */
|
||||
function convoKey(a: string, b: string): string {
|
||||
return [a, b].map((s) => s.toLowerCase()).sort().join('__');
|
||||
}
|
||||
|
||||
interface Convo {
|
||||
key: string;
|
||||
a: string;
|
||||
b: string;
|
||||
messages: Message[]; // oldest → newest
|
||||
lastAt: string;
|
||||
unread: number; // messages addressed to the viewer not yet read/acked
|
||||
}
|
||||
|
||||
/**
|
||||
* The /messages conversation view: messaging split cleanly out of /activity.
|
||||
* Two columns — left the conversation list grouped by {from,to} pair, right the
|
||||
* selected thread rendered as bubbles aligned by the viewer (`?as=<agent>`,
|
||||
* default `architect`). Live via its own EventSource('/events'), reloading on
|
||||
* message events. Reply indentation is driven by `replyTo`.
|
||||
*/
|
||||
export function renderMessagesHtml(cwd: string, asAgent?: string, selectedKey?: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const viewer = (asAgent && asAgent.trim()) || 'architect';
|
||||
|
||||
// Load full messages (frontmatter has replyTo + status); fall back to the
|
||||
// lightweight index entry when a file can't be read.
|
||||
const entries = listMessages(cwd);
|
||||
const messages: Message[] = entries.map((e) => {
|
||||
try {
|
||||
return getMessage(cwd, e.id).message;
|
||||
} catch {
|
||||
return {
|
||||
id: e.id,
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
text: e.text,
|
||||
taskId: e.taskId,
|
||||
status: (e.status as Message['status']) ?? 'unread',
|
||||
createdAt: e.createdAt,
|
||||
updatedAt: e.createdAt,
|
||||
} as Message;
|
||||
}
|
||||
});
|
||||
|
||||
// Group into conversations.
|
||||
const convos = new Map<string, Convo>();
|
||||
for (const m of messages) {
|
||||
const key = convoKey(m.from, m.to);
|
||||
let c = convos.get(key);
|
||||
if (!c) {
|
||||
c = { key, a: m.from, b: m.to, messages: [], lastAt: m.createdAt, unread: 0 };
|
||||
convos.set(key, c);
|
||||
}
|
||||
c.messages.push(m);
|
||||
if (m.createdAt > c.lastAt) c.lastAt = m.createdAt;
|
||||
const toViewer = m.to.toLowerCase() === viewer.toLowerCase();
|
||||
if (toViewer && (m.status === 'unread' || m.status === 'delivered')) c.unread += 1;
|
||||
}
|
||||
const convoList = [...convos.values()].sort((x, y) => y.lastAt.localeCompare(x.lastAt));
|
||||
for (const c of convoList) c.messages.sort((x, y) => x.createdAt.localeCompare(y.createdAt));
|
||||
|
||||
const selected = convoList.find((c) => c.key === selectedKey) ?? convoList[0];
|
||||
|
||||
const convoRows = convoList.length
|
||||
? convoList
|
||||
.map((c) => {
|
||||
const last = c.messages[c.messages.length - 1];
|
||||
const active = selected && c.key === selected.key;
|
||||
return `<a class="convo${active ? ' active' : ''}" href="/messages?as=${encodeURIComponent(viewer)}&c=${encodeURIComponent(c.key)}">
|
||||
<span class="pair">${agentAvatar(c.a, { size: 26 })}${agentAvatar(c.b, { size: 26 })}</span>
|
||||
<span class="convo-main">
|
||||
<span class="convo-names">${escapeHtml(c.a)} ↔ ${escapeHtml(c.b)}</span>
|
||||
<span class="convo-snippet">${escapeHtml(snippet(last?.text ?? ''))}</span>
|
||||
</span>
|
||||
<span class="convo-meta">
|
||||
${c.unread ? `<span class="badge">${c.unread}</span>` : ''}
|
||||
<span class="when">${escapeHtml(ago(c.lastAt))}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
})
|
||||
.join('')
|
||||
: '<div class="empty">No conversations yet.</div>';
|
||||
|
||||
const statusLabel = (s: string) => (s === 'acked' ? 'acked' : s === 'read' ? 'read' : s === 'delivered' ? 'delivered' : 'sent');
|
||||
|
||||
const thread = selected
|
||||
? selected.messages
|
||||
.map((m) => {
|
||||
const mine = m.from.toLowerCase() === viewer.toLowerCase();
|
||||
const isReply = !!m.replyTo;
|
||||
return `<div class="bubble-row ${mine ? 'sent' : 'recv'}${isReply ? ' reply' : ''}">
|
||||
<div class="bubble">
|
||||
<div class="bubble-head">${agentAvatar(m.from, { size: 20 })}<span class="from">${escapeHtml(m.from)}</span><span class="id">${escapeHtml(m.id)}</span>${m.taskId ? `<span class="task-pill">${escapeHtml(m.taskId)}</span>` : ''}</div>
|
||||
<div class="bubble-text">${escapeHtml(m.text)}</div>
|
||||
<div class="bubble-foot"><span class="when">${escapeHtml(ago(m.createdAt))}</span><span class="rstat" data-status="${escapeHtml(m.status)}">${escapeHtml(statusLabel(m.status))}</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('')
|
||||
: '<div class="empty">Pick a conversation.</div>';
|
||||
|
||||
const roster = getRoster(cwd).map((r) => r.name);
|
||||
const switchNames = ['architect', ...roster.filter((n) => n.toLowerCase() !== 'architect')];
|
||||
const switcher = switchNames
|
||||
.map(
|
||||
(n) =>
|
||||
`<a class="as-link${n.toLowerCase() === viewer.toLowerCase() ? ' active' : ''}" href="/messages?as=${encodeURIComponent(n)}${selected ? `&c=${encodeURIComponent(selected.key)}` : ''}">${escapeHtml(n)}</a>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const headerTitle = selected ? `${escapeHtml(selected.a)} ↔ ${escapeHtml(selected.b)}` : 'Messages';
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>AgentHub Messages</title>
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
${appHeaderCss()}
|
||||
body { padding: 96px 20px 32px; }
|
||||
main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(280px,.9fr) minmax(0,1.6fr);gap:12px;align-items:start; }
|
||||
.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px;min-width:0; }
|
||||
.panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
|
||||
h1 { font-size:16px;margin:0; }
|
||||
.id,.when { color:var(--muted);font:11px/1.4 var(--font-mono); }
|
||||
/* conversation list */
|
||||
.convo-list { display:grid;gap:6px; }
|
||||
.convo { display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;text-decoration:none;color:inherit;border:1px solid transparent;border-radius:8px;padding:8px; }
|
||||
.convo:hover { background:var(--raised); }
|
||||
.convo.active { background:var(--raised);border-color:var(--border); }
|
||||
.pair { display:inline-flex; }
|
||||
.pair .agent-avatar:nth-child(2) { margin-left:-8px;box-shadow:0 0 0 2px var(--surface); }
|
||||
.convo-main { min-width:0;display:grid;gap:2px; }
|
||||
.convo-names { font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
|
||||
.convo-snippet { color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
|
||||
.convo-meta { display:flex;flex-direction:column;align-items:flex-end;gap:4px; }
|
||||
.badge { background:var(--accent);color:#fff;border-radius:999px;font:10px/1 var(--font-mono);padding:3px 6px;min-width:16px;text-align:center; }
|
||||
/* switcher */
|
||||
.switch { display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:10px;color:var(--muted);font-size:11px; }
|
||||
.as-link { text-decoration:none;color:var(--muted);border:1px solid var(--border);border-radius:999px;padding:2px 9px;font:11px/1.4 var(--font-mono); }
|
||||
.as-link.active { color:var(--text);border-color:var(--accent);background:rgba(88,166,255,.10); }
|
||||
/* thread */
|
||||
.thread { display:grid;gap:8px;max-height:66vh;overflow-y:auto; }
|
||||
.bubble-row { display:flex; }
|
||||
.bubble-row.sent { justify-content:flex-end; }
|
||||
.bubble-row.recv { justify-content:flex-start; }
|
||||
.bubble-row.reply .bubble { margin-left:28px; }
|
||||
.bubble-row.sent.reply .bubble { margin-left:0;margin-right:28px; }
|
||||
.bubble { max-width:76%;background:var(--raised);border:1px solid var(--border);border-radius:12px;padding:8px 11px;min-width:0; }
|
||||
.bubble-row.sent .bubble { background:rgba(88,166,255,.12);border-color:rgba(88,166,255,.32); }
|
||||
.bubble-head { display:flex;align-items:center;gap:7px;margin-bottom:4px; }
|
||||
.from { font-size:12px;font-weight:600; }
|
||||
.task-pill { color:var(--status-review);border:1px solid rgba(210,153,34,.4);border-radius:999px;padding:0 6px;font:10px/1.5 var(--font-mono); }
|
||||
.bubble-text { overflow-wrap:anywhere;font-size:13px;line-height:1.5; }
|
||||
.bubble-foot { display:flex;gap:8px;align-items:center;margin-top:5px; }
|
||||
.rstat { font:10px/1.4 var(--font-mono);color:var(--muted); }
|
||||
.rstat[data-status="read"] { color:var(--accent); }
|
||||
.rstat[data-status="acked"] { color:var(--green); }
|
||||
.empty { color:var(--muted);font-size:12px;padding:8px; }
|
||||
@media (max-width:860px){ main{grid-template-columns:1fr} .thread{max-height:none} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${appHeader(config.projectName, 'messages')}
|
||||
<main>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h1>Conversations</h1><span class="when">${convoList.length}</span></div>
|
||||
<div class="convo-list">${convoRows}</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="switch"><span>View as</span>${switcher}</div>
|
||||
<div class="panel-head"><h1>${headerTitle}</h1></div>
|
||||
<div class="thread">${thread}</div>
|
||||
</section>
|
||||
</main>
|
||||
${taskModalHtml()}
|
||||
${appHeaderJs()}
|
||||
<script>
|
||||
(function(){
|
||||
if(!('EventSource' in window)) return;
|
||||
var t=null;
|
||||
try {
|
||||
var s=new EventSource('/events');
|
||||
s.onmessage=function(ev){
|
||||
try { var p=JSON.parse(ev.data); if(p&&p.type==='message'){ if(t)clearTimeout(t); t=setTimeout(function(){ location.reload(); }, 400); } } catch(_){}
|
||||
};
|
||||
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@ -4,7 +4,8 @@ import { getTaskActivity } from '../core/services/activityService.js';
|
||||
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||
import { createDecision, listDecisions } from '../core/services/decisionService.js';
|
||||
import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js';
|
||||
import { createMessage, listMessages, listInbox, markMessageRead, ackMessage, getMessage } from '../core/services/messageService.js';
|
||||
import { createAsk, listAsks, getAsk, answerAsk, escalateAsk } from '../core/services/askService.js';
|
||||
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
|
||||
import { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||
@ -12,14 +13,15 @@ import { computeBudget } from '../core/services/budgetService.js';
|
||||
import { getRoster } from '../core/services/rosterService.js';
|
||||
import { loadConfig, saveConfig } from '../core/config.js';
|
||||
import { renderActivityHtml } from './activity.js';
|
||||
import { renderBoardHtml } from './board.js';
|
||||
import { renderBoardHtml } from './board/index.js';
|
||||
import { renderTeamHtml } from './team.js';
|
||||
import { renderArchiveHtml } from './archive.js';
|
||||
import { renderDecisionsHtml } from './decisions.js';
|
||||
import { renderMessagesHtml } from './messages.js';
|
||||
import { renderTaskDetailHtml } from './taskDetail.js';
|
||||
import { eventBus, emitChange } from './events.js';
|
||||
import type { AgentHubEvent } from './events.js';
|
||||
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
|
||||
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join, extname, normalize } from 'node:path';
|
||||
@ -44,14 +46,11 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
|
||||
}
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||
// Never let a browser cache a hub HTML page — otherwise a stale board/team
|
||||
// page keeps showing old markup and doesn't reflect live task/agent state.
|
||||
app.addHook('onSend', async (_request, reply, payload) => {
|
||||
const ct = reply.getHeader('content-type');
|
||||
if (typeof ct === 'string' && ct.includes('text/html')) {
|
||||
reply.header('Cache-Control', 'no-store, must-revalidate');
|
||||
}
|
||||
return payload;
|
||||
// Default dynamic responses to no-store so board reloads/fetches never reuse
|
||||
// stale task JSON or HTML. Static asset routes override this with cacheable
|
||||
// headers, and the SSE route writes its own raw no-cache header.
|
||||
app.addHook('onRequest', async (_request, reply) => {
|
||||
reply.header('Cache-Control', 'no-store, must-revalidate');
|
||||
});
|
||||
|
||||
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
||||
@ -60,6 +59,16 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
||||
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
||||
|
||||
// AgentHub logo (header, splash, favicon). Read-only, cacheable.
|
||||
app.get('/logo.svg', async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(CARD_ASSETS_DIR, 'logo.svg'));
|
||||
return reply.type('image/svg+xml').header('Cache-Control', 'public, max-age=3600').send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send('not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the KPI-card Lottie animations + the vendored lottie-web player from the
|
||||
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
|
||||
app.get('/card-assets/*', async (request, reply) => {
|
||||
@ -212,6 +221,19 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
|
||||
app.get('/budget', async () => computeBudget(cwd));
|
||||
|
||||
// Auto-log a task status transition to its live console. Best-effort: the
|
||||
// .log write + task-log SSE fan-out must never fail the underlying mutation.
|
||||
// (No double-emission: publishLog rides the separate 'log' channel, and
|
||||
// fsWatch only watches .md files, so the .log append is not re-emitted.)
|
||||
const logTaskStatus = (id: string, text: string, agent?: string) => {
|
||||
try {
|
||||
const entry = appendTaskLog(cwd, id, { text, agent, level: 'status' });
|
||||
eventBus.publishLog({ taskId: id, ...entry });
|
||||
} catch {
|
||||
/* logging is best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Tasks ───────────────────────────────────────────────────────────────
|
||||
app.get('/tasks', async (request) => {
|
||||
const { status, role } = request.query as { status?: string; role?: string };
|
||||
@ -234,6 +256,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
claimedBy: task.claimedBy,
|
||||
reviewer: task.reviewer,
|
||||
},
|
||||
task.updatedAt,
|
||||
@ -303,6 +326,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
||||
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
||||
const assigned = assignTask(cwd, id, patch.assignedTo);
|
||||
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
|
||||
emitChange(
|
||||
{
|
||||
type: 'task',
|
||||
@ -312,6 +336,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
status: assigned.status,
|
||||
role: assigned.role,
|
||||
assignedTo: assigned.assignedTo,
|
||||
claimedBy: assigned.claimedBy,
|
||||
reviewer: assigned.reviewer,
|
||||
},
|
||||
assigned.updatedAt,
|
||||
@ -322,8 +347,18 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
let task: Task;
|
||||
switch (patch.status) {
|
||||
case 'in_progress':
|
||||
if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)');
|
||||
task = claimTask(cwd, id, patch.assignedTo);
|
||||
{
|
||||
const current = getTask(cwd, id).task;
|
||||
const agent = patch.assignedTo?.trim() || current.assignedTo || 'manual';
|
||||
// claimTask is race-guarded (open-only). Surface a lost race / non-open
|
||||
// claim as a clean 400 so the board drag reverts gracefully instead of
|
||||
// 500-ing, and a second agent can't clobber the first's claim.
|
||||
try {
|
||||
task = claimTask(cwd, id, agent);
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Cannot claim task');
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'done':
|
||||
task = doneTask(cwd, id, {
|
||||
@ -345,6 +380,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
||||
}
|
||||
|
||||
// Uniformly log the transition for every status-changing caller (claim /
|
||||
// review / done / cancel / reopen), so the live console tracks progress.
|
||||
const logText =
|
||||
task.status === 'in_progress' ? `Claimed by ${task.claimedBy ?? task.assignedTo ?? 'agent'}`
|
||||
: task.status === 'review' ? `Submitted for review${task.reviewer ? ` → ${task.reviewer}` : ''}`
|
||||
: task.status === 'done' ? `Approved — done${task.doneBy ? ` by ${task.doneBy}` : ''}`
|
||||
: task.status === 'cancelled' ? 'Cancelled'
|
||||
: task.status === 'open' ? 'Reopened'
|
||||
: `Status → ${task.status}`;
|
||||
logTaskStatus(id, logText, task.claimedBy ?? task.assignedTo ?? task.reviewer ?? task.doneBy);
|
||||
|
||||
emitChange(
|
||||
{
|
||||
type: 'task',
|
||||
@ -354,6 +400,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
status: task.status,
|
||||
role: task.role,
|
||||
assignedTo: task.assignedTo,
|
||||
claimedBy: task.claimedBy,
|
||||
reviewer: task.reviewer,
|
||||
},
|
||||
task.updatedAt,
|
||||
@ -423,9 +470,14 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
// Direct agent-to-agent / architect-to-agent messages. GET /messages returns
|
||||
// all (architect view); GET /messages/inbox?agent=X&unread=1 returns one
|
||||
// agent's inbox.
|
||||
app.get('/messages', async (request) => {
|
||||
const { agent, unread } = request.query as { agent?: string; unread?: string };
|
||||
app.get('/messages', async (request, reply) => {
|
||||
const { agent, unread, as, c } = request.query as { agent?: string; unread?: string; as?: string; c?: string };
|
||||
// JSON inbox contract (remoteClient.getInbox / agenthub_inbox) — unchanged.
|
||||
if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' });
|
||||
// Browser navigation → the /messages conversation view.
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type('text/html; charset=utf-8').send(renderMessagesHtml(cwd, as, c));
|
||||
}
|
||||
return listMessages(cwd);
|
||||
});
|
||||
app.post('/messages', async (request, reply) => {
|
||||
@ -441,11 +493,23 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
action: 'created',
|
||||
id: message.id,
|
||||
title: `${message.from} → ${message.to}`,
|
||||
assignedTo: message.to,
|
||||
},
|
||||
message.updatedAt,
|
||||
);
|
||||
return message;
|
||||
});
|
||||
// Single message (frontmatter + body) — used by `message reply` to load the
|
||||
// parent it answers. JSON only.
|
||||
app.get('/messages/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
try {
|
||||
const { message, body } = getMessage(cwd, id);
|
||||
return { message, body };
|
||||
} catch {
|
||||
return notFound(reply, 'Message');
|
||||
}
|
||||
});
|
||||
app.post('/messages/:id/read', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
try {
|
||||
@ -461,6 +525,80 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
|
||||
}
|
||||
});
|
||||
app.post('/messages/:id/ack', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { by } = (request.body ?? {}) as { by?: string };
|
||||
try {
|
||||
const message = ackMessage(cwd, id, by);
|
||||
// Ack receipt: mirror of /read so the sender's stream shows the strongest state.
|
||||
emitChange(
|
||||
{ type: 'message', action: 'updated', id: message.id, status: 'acked', title: `${message.from} → ${message.to}`, assignedTo: message.to },
|
||||
message.updatedAt,
|
||||
);
|
||||
return message;
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Asks ────────────────────────────────────────────────────────────────
|
||||
// Blocking decision-routing questions (TSK-0118). POST creates + routes to the
|
||||
// architect; answer/escalate close them. Each mutation fires an 'ask' event so
|
||||
// a waiting `ask --wait` / agenthub_ask wakes.
|
||||
app.get('/asks', async (request) => {
|
||||
const { to, status } = request.query as { to?: string; status?: string };
|
||||
return listAsks(cwd, { to, status });
|
||||
});
|
||||
app.post('/asks', async (request, reply) => {
|
||||
let ask: Ask;
|
||||
try {
|
||||
ask = createAsk(cwd, request.body as Partial<Ask>);
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Invalid ask');
|
||||
}
|
||||
emitChange(
|
||||
{ type: 'ask', action: 'created', id: ask.id, title: `${ask.from} → ${ask.to}`, status: ask.status, assignedTo: ask.to },
|
||||
ask.updatedAt,
|
||||
);
|
||||
return ask;
|
||||
});
|
||||
app.get('/asks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
try {
|
||||
const { ask, body } = getAsk(cwd, id);
|
||||
return { ask, body };
|
||||
} catch {
|
||||
return notFound(reply, 'Ask');
|
||||
}
|
||||
});
|
||||
app.post('/asks/:id/answer', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { text, by } = (request.body ?? {}) as { text?: string; by?: string };
|
||||
try {
|
||||
const ask = answerAsk(cwd, id, text ?? '', by);
|
||||
emitChange(
|
||||
{ type: 'ask', action: 'updated', id: ask.id, title: `${ask.from} → ${ask.to}`, status: ask.status, assignedTo: ask.to },
|
||||
ask.updatedAt,
|
||||
);
|
||||
return ask;
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Ask not found');
|
||||
}
|
||||
});
|
||||
app.post('/asks/:id/escalate', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { note } = (request.body ?? {}) as { note?: string };
|
||||
try {
|
||||
const ask = escalateAsk(cwd, id, note);
|
||||
emitChange(
|
||||
{ type: 'ask', action: 'updated', id: ask.id, title: `${ask.from} → ${ask.to}`, status: ask.status, assignedTo: ask.escalatedTo },
|
||||
ask.updatedAt,
|
||||
);
|
||||
return ask;
|
||||
} catch (err) {
|
||||
return badRequest(reply, err instanceof Error ? err.message : 'Ask not found');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Memory ──────────────────────────────────────────────────────────────
|
||||
app.get('/memory', async () => listMemory(cwd));
|
||||
|
||||
@ -3,6 +3,7 @@ import { getTask } from '../core/services/taskService.js';
|
||||
import { getTaskActivity } from '../core/services/activityService.js';
|
||||
import { getDecision } from '../core/services/decisionService.js';
|
||||
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
|
||||
import { readTaskLog, type TaskLogEntry } from '../core/services/taskLogService.js';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
||||
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
|
||||
|
||||
@ -127,6 +128,13 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
.join('')
|
||||
: '<div class="empty">No linked decisions for this task.</div>';
|
||||
|
||||
const logEntries: TaskLogEntry[] = readTaskLog(cwd, id);
|
||||
const logLine = (e: TaskLogEntry) =>
|
||||
`<div class="log-line" data-level="${escapeHtml(e.level ?? 'info')}"><span class="log-ts">${escapeHtml(ago(e.ts))}</span>${e.agent ? `<span class="log-agent">${escapeHtml(e.agent)}</span>` : ''}<span class="log-text">${escapeHtml(e.text)}</span></div>`;
|
||||
const consoleRows = logEntries.length
|
||||
? logEntries.map(logLine).join('')
|
||||
: '<div class="empty" data-empty>No console output yet.</div>';
|
||||
|
||||
const activityRows = activity.length
|
||||
? activity
|
||||
.map(
|
||||
@ -178,6 +186,14 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
|
||||
.summary { min-width:0;overflow-wrap:anywhere; }
|
||||
.empty { color:var(--muted); }
|
||||
.console { max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px;font:12px/1.5 var(--font-mono); }
|
||||
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
|
||||
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
|
||||
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
|
||||
.log-text { color:var(--text);min-width:0; }
|
||||
.log-line[data-level="status"] .log-text { color:var(--status-review); }
|
||||
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
|
||||
.log-line[data-level="error"] .log-text { color:#F85149; }
|
||||
@media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
|
||||
</style>
|
||||
</head>
|
||||
@ -190,6 +206,10 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
<div class="stats">${taskStats}</div>
|
||||
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Live Console</h2>
|
||||
<div class="console" id="taskConsole">${consoleRows}</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Handoffs</h2>
|
||||
${handoffRows}
|
||||
@ -205,6 +225,26 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
||||
</main>
|
||||
${taskModalHtml()}
|
||||
${appHeaderJs()}
|
||||
<script>
|
||||
(function(){
|
||||
var TASK_ID = ${JSON.stringify(task.id)};
|
||||
var box = document.getElementById('taskConsole');
|
||||
if(!box || !('EventSource' in window)) return;
|
||||
function fmtAgo(iso){ var t=Date.parse(iso); if(isNaN(t))return''; var s=Math.max(0,Math.floor((Date.now()-t)/1000)); if(s<60)return s+'s'; var m=Math.floor(s/60); if(m<60)return m+'m'; var h=Math.floor(m/60); if(h<24)return h+'h'; return Math.floor(h/24)+'d'; }
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
function append(p){
|
||||
var empty=box.querySelector('[data-empty]'); if(empty) empty.remove();
|
||||
var el=document.createElement('div'); el.className='log-line'; el.setAttribute('data-level', p.level||'info');
|
||||
el.innerHTML='<span class="log-ts">'+esc(fmtAgo(p.ts||new Date().toISOString()))+' ago</span>'+(p.agent?'<span class="log-agent">'+esc(p.agent)+'</span>':'')+'<span class="log-text">'+esc(p.text)+'</span>';
|
||||
box.appendChild(el); box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
try {
|
||||
var s=new EventSource('/events');
|
||||
s.addEventListener('task-log', function(ev){ try{ var p=JSON.parse(ev.data); if(p && p.taskId===TASK_ID) append(p); }catch(_){} });
|
||||
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@ -291,43 +291,74 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
|
||||
// identically on the non-board pages.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HeaderPage = 'board' | 'team' | 'activity' | 'decisions' | 'archive' | 'task';
|
||||
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task';
|
||||
|
||||
/** CSS for ONLY the shared header (board v2 reuses this inside boardV2Css). */
|
||||
export function appHeaderOnlyCss(): string {
|
||||
return `
|
||||
/* v2 header tokens (self-contained — host pages define different palettes) */
|
||||
:root {
|
||||
--b2-border: rgba(255,255,255,.09);
|
||||
--b2-surface: rgba(255,255,255,.045);
|
||||
--b2-text: #eef1f8;
|
||||
--b2-muted: #8fa0c6;
|
||||
--b2-green: #34d399;
|
||||
--b2-amber: #fbbf24;
|
||||
}
|
||||
@keyframes b2-ping { 0% { box-shadow: 0 0 0 0 rgba(52,211,179,.55); } 70% { box-shadow: 0 0 0 7px rgba(52,211,179,0); } 100% { box-shadow: 0 0 0 0 rgba(52,211,179,0); } }
|
||||
.app-header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
|
||||
display: flex; align-items: center; gap: 14px; min-height: 66px;
|
||||
margin: 0; padding: 10px 22px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||
background: rgba(9, 12, 24, .82); backdrop-filter: blur(12px);
|
||||
}
|
||||
.app-header .b2-brand { display: flex; align-items: center; gap: 10px; font-size: 16px; }
|
||||
.app-header .b2-brand b { font-weight: 700; letter-spacing: .01em; }
|
||||
.b2-logo-img { display: block; }
|
||||
.app-header .b2-proj {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
color: var(--b2-muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, monospace);
|
||||
padding: 4px 10px; border-radius: 8px; border: 1px solid transparent;
|
||||
max-width: 34vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.app-header .b2-proj:hover { background: rgba(255,255,255,.05); border-color: var(--b2-border); color: #c6d0e8; }
|
||||
.b2-chev { font-size: 9px; opacity: .7; }
|
||||
.app-header .b2-nav { display: flex; align-items: center; gap: 2px; margin-left: 8px;
|
||||
border: 1px solid var(--b2-border); background: var(--b2-surface); padding: 3px; border-radius: 9px; }
|
||||
.app-header .b2-nav a {
|
||||
min-height: 32px; display: inline-flex; align-items: center;
|
||||
padding: 0 13px; border-radius: 7px; font-size: 13px; font-weight: 500;
|
||||
color: var(--b2-muted); text-decoration: none; transition: color 160ms ease, background 160ms ease;
|
||||
}
|
||||
.app-header .b2-nav a:hover { color: var(--b2-text); background: rgba(255,255,255,.05); }
|
||||
.app-header .b2-nav a.active { color: var(--b2-text); background: rgba(255,255,255,.08); font-weight: 600; }
|
||||
.b2-hdr-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||||
.b2-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
min-height: 36px; padding: 0 16px; border: 0; border-radius: 9px;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff; font: 600 13px/1 var(--font-sans, system-ui, sans-serif); cursor: pointer;
|
||||
box-shadow: 0 4px 18px rgba(99,102,241,.28);
|
||||
transition: transform 120ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||
}
|
||||
.b2-btn:hover { filter: brightness(1.1); box-shadow: 0 6px 22px rgba(99,102,241,.38); }
|
||||
.b2-btn:active { transform: translateY(1px); }
|
||||
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-width: 116px; min-height: 34px; color: var(--b2-muted); font: 12px/1 var(--font-mono, ui-monospace, monospace); white-space: nowrap; }
|
||||
.b2-live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--b2-green); animation: b2-ping 2s infinite; }
|
||||
.sse-status.stale .b2-live-dot { background: var(--b2-amber); animation: none; }
|
||||
.sse-status.down .b2-live-dot { background: #F85149; animation: none; }
|
||||
@media (max-width: 680px) {
|
||||
.app-header { flex-wrap: wrap; gap: 10px 12px; }
|
||||
.b2-hdr-right { margin-left: auto; }
|
||||
.app-header .b2-nav { order: 3; width: 100%; }
|
||||
.app-header .b2-nav a { flex: 1; justify-content: center; }
|
||||
}`;
|
||||
}
|
||||
|
||||
/** CSS for the shared header + new-task modal + toasts. Include once per page. */
|
||||
export function appHeaderCss(): string {
|
||||
return `
|
||||
.app-header {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
|
||||
display: flex; align-items: center; gap: 16px; min-height: 64px;
|
||||
margin: 0; padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(15, 23, 42, .96); backdrop-filter: blur(10px);
|
||||
}
|
||||
.app-header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.app-header .mark { width: 34px; height: 34px; flex: 0 0 auto; border: 1px solid rgba(88,166,255,.38); border-radius: 8px; display: grid; place-items: center; background: var(--raised); }
|
||||
.app-header .mark svg { width: 22px; height: 22px; }
|
||||
.brand-copy { min-width: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.brand-title { font-weight: 700; font-size: 17px; }
|
||||
.project-name { color: var(--muted); font-size: 12px; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }
|
||||
.header-spacer { flex: 1; }
|
||||
.app-header .nav { display: flex; align-items: center; gap: 4px; border: 1px solid var(--border); background: var(--surface); padding: 3px; border-radius: 8px; }
|
||||
.app-header .nav-link { min-height: 34px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 6px; color: var(--muted); font-size: 13px; text-decoration: none; transition: color 180ms ease, background 180ms ease; }
|
||||
.app-header .nav-link:hover, .app-header .nav-link.active { color: var(--text); background: var(--raised); }
|
||||
.new-task-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid rgba(88,166,255,.45); background: linear-gradient(180deg, rgba(88,166,255,.20), rgba(88,166,255,.10)); color: var(--text); font: 600 13px/1 var(--font-sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, box-shadow 160ms ease; }
|
||||
.new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); }
|
||||
.new-task-btn:active { transform: translateY(1px); }
|
||||
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
|
||||
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-width: 116px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; }
|
||||
.conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); }
|
||||
.sse-status.stale .conn-dot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); }
|
||||
.sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); }
|
||||
@media (max-width: 680px) {
|
||||
.app-header { flex-wrap: wrap; gap: 10px 12px; }
|
||||
.header-spacer { display: none; }
|
||||
.app-header .nav { order: 3; width: 100%; }
|
||||
.app-header .nav-link { flex: 1; justify-content: center; }
|
||||
}
|
||||
|
||||
return appHeaderOnlyCss() + `
|
||||
/* ── New-task modal ─────────────────────────────────────────────────── */
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); backdrop-filter: blur(3px); animation: modalFade 140ms ease; }
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
@ -366,33 +397,27 @@ export function appHeaderCss(): string {
|
||||
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`;
|
||||
}
|
||||
|
||||
/** The shared header markup: brand, nav, New-task button, live status. */
|
||||
/** The shared header markup (v2): logo, project cell, nav, New-task, live status. */
|
||||
export function appHeader(projectName: string, current: HeaderPage): string {
|
||||
const mark = `<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="M5 7.5h8.5a5.5 5.5 0 0 1 0 11H5v-11Z" stroke="#58A6FF" stroke-width="1.8"/><path d="M8.5 5.5h7a4 4 0 0 1 0 8h-7v-8Z" stroke="#22C55E" stroke-width="1.8"/></svg></span>`;
|
||||
const link = (label: string, path: string, key: HeaderPage) =>
|
||||
`<a class="nav-link${current === key ? ' active' : ''}" href="${path}">${label}</a>`;
|
||||
`<a class="${current === key ? 'active' : ''}" href="${path}"${current === key ? ' aria-current="page"' : ''}>${label}</a>`;
|
||||
return `
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
${mark}
|
||||
<div class="brand-copy">
|
||||
<span class="brand-title">AgentHub</span>
|
||||
<span class="project-name" id="projectName">${escapeHtml(projectName)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="header-spacer"></span>
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<span class="b2-brand"><img src="/logo.svg" alt="" width="24" height="24" class="b2-logo-img"><b>agenthub</b></span>
|
||||
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ <span id="projectName">${escapeHtml(projectName)}</span> <span class="b2-chev">▾</span></span>
|
||||
<nav class="b2-nav" aria-label="Primary">
|
||||
${link('Board', '/board', 'board')}
|
||||
${link('Team', '/team', 'team')}
|
||||
${link('Activity', '/activity', 'activity')}
|
||||
${link('Messages', '/messages', 'messages')}
|
||||
${link('Decisions', '/decisions', 'decisions')}
|
||||
</nav>
|
||||
<button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<span class="plus" aria-hidden="true">+</span> New task
|
||||
</button>
|
||||
<span class="sse-status stale" id="sseStatus">
|
||||
<span class="conn-dot" aria-hidden="true"></span>
|
||||
<span id="sseLabel">connecting</span>
|
||||
<span class="b2-hdr-right">
|
||||
<button class="b2-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">+ New task</button>
|
||||
<span class="sse-status stale" id="sseStatus">
|
||||
<span class="b2-live-dot" aria-hidden="true"></span>
|
||||
<span id="sseLabel">connecting</span>
|
||||
</span>
|
||||
</span>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
93
tests/ask-wait.test.ts
Normal file
93
tests/ask-wait.test.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { buildApp } from '../src/server/index.js';
|
||||
import { startServer } from '../src/server/index.js';
|
||||
import { waitForAsk } from '../src/cli/commands/ask.js';
|
||||
import type { Ask } from '../src/core/schema.js';
|
||||
|
||||
describe('ask routes (TSK-0118)', () => {
|
||||
let cwd: string;
|
||||
let app: ReturnType<typeof buildApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-routes-'));
|
||||
init(cwd, { projectName: 'ask-routes', yes: true });
|
||||
app = buildApp(cwd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('POST/GET/answer/escalate roundtrip', async () => {
|
||||
const created = JSON.parse((await app.inject({ method: 'POST', url: '/asks', payload: { from: 'kimi', question: 'q?' } })).payload) as Ask;
|
||||
expect(created.id).toBe('ASK-0001');
|
||||
expect(created.to).toBe('claude');
|
||||
expect(created.status).toBe('pending');
|
||||
|
||||
// GET /asks — the data source agenthub_work's architect branch surfaces.
|
||||
const pending = JSON.parse((await app.inject({ method: 'GET', url: '/asks?status=pending' })).payload) as Ask[];
|
||||
expect(pending.map((a) => a.id)).toContain('ASK-0001');
|
||||
|
||||
const answered = JSON.parse((await app.inject({ method: 'POST', url: '/asks/ASK-0001/answer', payload: { text: 'yes', by: 'claude' } })).payload) as Ask;
|
||||
expect(answered.status).toBe('answered');
|
||||
expect(answered.answer).toBe('yes');
|
||||
|
||||
const created2 = JSON.parse((await app.inject({ method: 'POST', url: '/asks', payload: { from: 'kimi', question: 'ship?' } })).payload) as Ask;
|
||||
const escalated = JSON.parse((await app.inject({ method: 'POST', url: `/asks/${created2.id}/escalate`, payload: { note: 'ceo call' } })).payload) as Ask;
|
||||
expect(escalated.status).toBe('escalated');
|
||||
expect(escalated.escalatedTo).toBe('ceo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ask --wait (server SSE)', () => {
|
||||
let cwd: string;
|
||||
let server: Awaited<ReturnType<typeof startServer>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-wait-'));
|
||||
init(cwd, { projectName: 'ask-wait', yes: true });
|
||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.app.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves with the answer when the architect answers', async () => {
|
||||
const created = (await fetch(`${server.url}/asks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: 'kimi', question: 'which driver?' }),
|
||||
}).then((r) => r.json())) as Ask;
|
||||
|
||||
const waitP = waitForAsk(server.url, created.id, 4);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await fetch(`${server.url}/asks/${created.id}/answer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: 'node:sqlite', by: 'claude' }),
|
||||
});
|
||||
|
||||
const settled = await waitP;
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe('answered');
|
||||
expect(settled?.answer).toBe('node:sqlite');
|
||||
}, 7000);
|
||||
|
||||
it('times out cleanly when there is no answer', async () => {
|
||||
const created = (await fetch(`${server.url}/asks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: 'kimi', question: 'no answer coming' }),
|
||||
}).then((r) => r.json())) as Ask;
|
||||
|
||||
const settled = await waitForAsk(server.url, created.id, 1);
|
||||
expect(settled).toBeNull();
|
||||
}, 4000);
|
||||
});
|
||||
80
tests/askService.test.ts
Normal file
80
tests/askService.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { createAsk, answerAsk, escalateAsk, listAsks, getAsk } from '../src/core/services/askService.js';
|
||||
import { askCreate, askAnswer, askEscalate } from '../src/cli/commands/ask.js';
|
||||
import { addMemory, searchMemory } from '../src/core/services/memoryService.js';
|
||||
|
||||
describe('askService (TSK-0118)', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-ask-'));
|
||||
await init(cwd, { yes: true, projectName: 'test' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('routes to the architect (never the CEO) by default', () => {
|
||||
const ask = createAsk(cwd, { from: 'kimi', question: 'Which DB driver?' });
|
||||
expect(ask.id).toMatch(/^ASK-\d{4}$/);
|
||||
expect(ask.to).toBe('claude'); // config.roles.architect.preferredAgent
|
||||
expect(ask.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('refuses to route to the CEO — reroutes to the architect', () => {
|
||||
const ask = createAsk(cwd, { from: 'kimi', to: 'ceo', question: 'ship it?' });
|
||||
expect(ask.to).toBe('claude');
|
||||
});
|
||||
|
||||
it('answerAsk closes it as answered with the answer + author', () => {
|
||||
const ask = createAsk(cwd, { from: 'kimi', question: 'Which DB driver?' });
|
||||
const answered = answerAsk(cwd, ask.id, 'node:sqlite', 'claude');
|
||||
expect(answered.status).toBe('answered');
|
||||
expect(answered.answer).toBe('node:sqlite');
|
||||
expect(answered.answeredBy).toBe('claude');
|
||||
expect(getAsk(cwd, ask.id).ask.status).toBe('answered');
|
||||
});
|
||||
|
||||
it('escalateAsk flips to escalated → ceo (single channel, no second ask)', () => {
|
||||
const ask = createAsk(cwd, { from: 'kimi', question: 'Publish to OSS?' });
|
||||
const escalated = escalateAsk(cwd, ask.id, 'OSS decision — CEO call');
|
||||
expect(escalated.status).toBe('escalated');
|
||||
expect(escalated.escalatedTo).toBe('ceo');
|
||||
});
|
||||
|
||||
it('listAsks filters by status and recipient', () => {
|
||||
const a1 = createAsk(cwd, { from: 'kimi', question: 'q1' });
|
||||
createAsk(cwd, { from: 'codex', question: 'q2' });
|
||||
answerAsk(cwd, a1.id, 'yes');
|
||||
|
||||
expect(listAsks(cwd)).toHaveLength(2);
|
||||
expect(listAsks(cwd, { status: 'pending' })).toHaveLength(1);
|
||||
expect(listAsks(cwd, { to: 'claude' })).toHaveLength(2);
|
||||
expect(listAsks(cwd, { to: 'nobody' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('asks are NOT indexed in FTS5 (memory search never returns them)', () => {
|
||||
createAsk(cwd, { from: 'kimi', question: 'znamqvist widget architecture' });
|
||||
addMemory(cwd, { title: 'note', content: 'znamqvist widget architecture' });
|
||||
|
||||
const hits = searchMemory(cwd, 'znamqvist');
|
||||
expect(hits.some((r) => r.type === 'ask')).toBe(false); // ask excluded from FTS
|
||||
expect(hits.some((r) => r.type === 'memory')).toBe(true); // search still works
|
||||
});
|
||||
|
||||
it('ask-cmd helpers create/answer/escalate through the service', () => {
|
||||
const ask = askCreate(cwd, { from: 'kimi', question: 'via cmd?' });
|
||||
expect(ask.status).toBe('pending');
|
||||
askAnswer(cwd, ask.id, 'ok', 'claude');
|
||||
expect(getAsk(cwd, ask.id).ask.status).toBe('answered');
|
||||
|
||||
const ask2 = askCreate(cwd, { from: 'kimi', question: 'escalate me' });
|
||||
askEscalate(cwd, ask2.id, 'needs ceo');
|
||||
expect(getAsk(cwd, ask2.id).ask.status).toBe('escalated');
|
||||
});
|
||||
});
|
||||
35
tests/boardV2-chrome.test.ts
Normal file
35
tests/boardV2-chrome.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { headerHtml, splashHtml, splashJs } from '../src/server/board/chrome.js';
|
||||
|
||||
describe('headerHtml', () => {
|
||||
it('renders logo, project cell with chevron, nav and new-task button', () => {
|
||||
const h = headerHtml('my-project');
|
||||
expect(h).toContain('/logo.svg');
|
||||
expect(h).toContain('my-project');
|
||||
expect(h).toContain('b2-proj'); // project cell (visual only, dropdown comes with v3)
|
||||
expect(h).toContain('▾');
|
||||
expect(h).toContain('+ New task');
|
||||
expect(h).toContain('id="newTaskBtn"');
|
||||
// SSE status mount compatible with the ported v1 connection JS
|
||||
expect(h).toContain('id="sseStatus"');
|
||||
expect(h).toContain('id="sseLabel"');
|
||||
});
|
||||
it('escapes the project name', () => {
|
||||
expect(headerHtml('<b>x</b>')).not.toContain('<b>x</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splash', () => {
|
||||
it('renders overlay with logo and wordmark', () => {
|
||||
const s = splashHtml();
|
||||
expect(s).toContain('id="b2-splash"');
|
||||
expect(s).toContain('/logo.svg');
|
||||
expect(s).toContain('agenthub');
|
||||
});
|
||||
it('fade-out script has a hard timeout and never blocks', () => {
|
||||
const js = splashJs();
|
||||
expect(js).toContain('b2-splash');
|
||||
expect(js).toContain('1500'); // hard cap in ms
|
||||
expect(js).toContain('__b2SplashDone');
|
||||
});
|
||||
});
|
||||
30
tests/boardV2-kpis.test.ts
Normal file
30
tests/boardV2-kpis.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kpiSkeletonHtml, kpiJs } from '../src/server/board/kpis.js';
|
||||
|
||||
describe('kpiSkeletonHtml', () => {
|
||||
it('renders the four KPI cards with stable ids', () => {
|
||||
const h = kpiSkeletonHtml();
|
||||
for (const id of ['kpiOpen', 'kpiInProgress', 'kpiReview', 'kpiDone']) {
|
||||
expect(h).toContain(`id="${id}"`);
|
||||
}
|
||||
expect(h).toContain('Open');
|
||||
expect(h).toContain('In Progress');
|
||||
expect(h).toContain('Review');
|
||||
expect(h).toContain('Done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kpiJs', () => {
|
||||
it('injects the pure helpers and an updateKpis entry point', () => {
|
||||
const js = kpiJs();
|
||||
for (const fn of ['capChips', 'doneStats', 'backlogSeries', 'areaPath', 'laneChips']) {
|
||||
expect(js).toContain(`function ${fn}`);
|
||||
}
|
||||
expect(js).toContain('window.__b2UpdateKpis');
|
||||
});
|
||||
it('co-injects the shared constants the helpers depend on', () => {
|
||||
const js = kpiJs();
|
||||
expect(js).toContain('DAY_MS');
|
||||
expect(js).toContain('function dayStart');
|
||||
});
|
||||
});
|
||||
45
tests/boardV2-sidebar.test.ts
Normal file
45
tests/boardV2-sidebar.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sidebarHtml, sidebarJs } from '../src/server/board/sidebar.js';
|
||||
import { v1BudgetJs } from '../src/server/board/v1Budget.js';
|
||||
|
||||
describe('sidebarHtml', () => {
|
||||
it('renders budget card without über-tabs (tabs live in the donut card)', () => {
|
||||
const h = sidebarHtml();
|
||||
expect(h).toContain('id="b2Budget"');
|
||||
expect(h).not.toContain('data-otab');
|
||||
expect(h).toContain('id="budgetReset"');
|
||||
expect(h).toContain('id="b2Feed"');
|
||||
});
|
||||
it('keeps the v1 budget mount points for the ported JS', () => {
|
||||
const h = sidebarHtml();
|
||||
expect(h).toContain('id="budgetRows"');
|
||||
expect(h).toContain('id="budgetTotal"');
|
||||
expect(h).toContain('data-budget-mode="session"');
|
||||
expect(h).toContain('data-budget-mode="total"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebarJs', () => {
|
||||
const js = sidebarJs(v1BudgetJs);
|
||||
it('renders throughput and feed without über-tab logic', () => {
|
||||
expect(js).not.toContain('agenthub-budget-otab');
|
||||
expect(js).toContain('function throughputSeries');
|
||||
expect(js).toContain('function dayStart');
|
||||
expect(js).toContain('__b2RenderThroughput');
|
||||
expect(js).toContain('__b2FeedPush');
|
||||
});
|
||||
it('keeps v1 reset behaviour and session/total modes', () => {
|
||||
expect(js).toContain('writeBaseline');
|
||||
expect(js).toContain('agenthub-budget-mode');
|
||||
expect(js).toContain('agenthub-donut-metric');
|
||||
expect(js).toContain('budgetReset');
|
||||
expect(js).toContain('renderBudget');
|
||||
});
|
||||
it('hosts the three tabs Token/Kosten/Verlauf inside the donut card', () => {
|
||||
expect(js).toContain('data-donut-tab="tokens"');
|
||||
expect(js).toContain('data-donut-tab="cost"');
|
||||
expect(js).toContain('data-donut-tab="verlauf"');
|
||||
expect(js).toContain('donutTabsHtml');
|
||||
expect(js).toContain('donutCardShell');
|
||||
});
|
||||
});
|
||||
16
tests/boardV2-styles.test.ts
Normal file
16
tests/boardV2-styles.test.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { boardV2Css } from '../src/server/board/styles.js';
|
||||
|
||||
describe('boardV2Css', () => {
|
||||
it('contains the glass surface tokens and keyframes', () => {
|
||||
const css = boardV2Css();
|
||||
expect(css).toContain('--b2-bg');
|
||||
expect(css).toContain('rgba(255,255,255,.045)'); // glass surface
|
||||
expect(css).toContain('@keyframes b2-rise');
|
||||
expect(css).toContain('@keyframes b2-fill');
|
||||
expect(css).toContain('@keyframes b2-ping');
|
||||
});
|
||||
it('respects prefers-reduced-motion', () => {
|
||||
expect(boardV2Css()).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
});
|
||||
81
tests/boardV2-viewmodel.test.ts
Normal file
81
tests/boardV2-viewmodel.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
capChips, doneStats, backlogSeries, throughputSeries, areaPath,
|
||||
type KpiTask,
|
||||
} from '../src/server/board/viewmodel.js';
|
||||
|
||||
const day = 24 * 3600 * 1000;
|
||||
const iso = (msAgo: number) => new Date(Date.now() - msAgo).toISOString();
|
||||
|
||||
describe('capChips', () => {
|
||||
it('shows all chips when at most max', () => {
|
||||
const chips = [{ name: 'claude', minutes: 5 }, { name: 'codex', minutes: 2 }];
|
||||
expect(capChips(chips, 3)).toEqual({ visible: chips, hidden: 0 });
|
||||
});
|
||||
it('caps at max and reports the hidden count', () => {
|
||||
const chips = ['a', 'b', 'c', 'd', 'e'].map((name) => ({ name, minutes: 1 }));
|
||||
const r = capChips(chips, 3);
|
||||
expect(r.visible.map((c) => c.name)).toEqual(['a', 'b', 'c']);
|
||||
expect(r.hidden).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneStats', () => {
|
||||
it('computes share and weekly count, excluding cancelled from total', () => {
|
||||
const tasks: KpiTask[] = [
|
||||
{ id: '1', status: 'done', updatedAt: iso(2 * day) },
|
||||
{ id: '2', status: 'done', updatedAt: iso(10 * day) },
|
||||
{ id: '3', status: 'open' },
|
||||
{ id: '4', status: 'cancelled' },
|
||||
];
|
||||
const r = doneStats(tasks);
|
||||
expect(r.done).toBe(2);
|
||||
expect(r.total).toBe(3); // cancelled excluded
|
||||
expect(r.pct).toBe(67);
|
||||
expect(r.doneThisWeek).toBe(1);
|
||||
});
|
||||
it('handles empty input', () => {
|
||||
expect(doneStats([])).toEqual({ done: 0, total: 0, pct: 0, doneThisWeek: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlogSeries', () => {
|
||||
it('returns one value per day, oldest first, ending today', () => {
|
||||
const tasks: KpiTask[] = [
|
||||
{ id: '1', status: 'open', createdAt: iso(3 * day) },
|
||||
{ id: '2', status: 'done', createdAt: iso(13 * day), updatedAt: iso(1 * day) },
|
||||
];
|
||||
const s = backlogSeries(tasks, 14);
|
||||
expect(s).toHaveLength(14);
|
||||
expect(s[13]).toBe(1); // today: only the open task is backlog
|
||||
expect(s[0]).toBe(1); // 13 days ago: only task 2 existed and was not done yet
|
||||
});
|
||||
});
|
||||
|
||||
describe('throughputSeries', () => {
|
||||
it('counts done tasks per day', () => {
|
||||
const tasks: KpiTask[] = [
|
||||
{ id: '1', status: 'done', updatedAt: iso(0) },
|
||||
{ id: '2', status: 'done', updatedAt: iso(0) },
|
||||
{ id: '3', status: 'done', updatedAt: iso(5 * day) },
|
||||
{ id: '4', status: 'open', updatedAt: iso(0) },
|
||||
];
|
||||
const s = throughputSeries(tasks, 14);
|
||||
expect(s).toHaveLength(14);
|
||||
expect(s[13]).toBe(2); // today
|
||||
expect(s[8]).toBe(1); // 5 days ago
|
||||
expect(s.reduce((a, b) => a + b, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areaPath', () => {
|
||||
it('builds line and area paths scaled to width/height', () => {
|
||||
const { line, area } = areaPath([0, 5, 10], 100, 50);
|
||||
expect(line).toBe('M0,50 L50,25 L100,0');
|
||||
expect(area).toBe('M0,50 L50,25 L100,0 L100,50 L0,50 Z');
|
||||
});
|
||||
it('flattens when all values are equal (no division by zero)', () => {
|
||||
const { line } = areaPath([3, 3, 3], 90, 30);
|
||||
expect(line).toBe('M0,15 L45,15 L90,15');
|
||||
});
|
||||
});
|
||||
42
tests/boardV2.test.ts
Normal file
42
tests/boardV2.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderBoardHtml } from '../src/server/board/index.js';
|
||||
|
||||
describe('renderBoardHtml (v2)', () => {
|
||||
const html = renderBoardHtml('demo-project');
|
||||
|
||||
it('is a full html document with favicon and splash', () => {
|
||||
expect(html).toMatch(/^<!doctype html>/i);
|
||||
expect(html).toContain('rel="icon" href="/logo.svg"');
|
||||
expect(html).toContain('id="b2-splash"');
|
||||
});
|
||||
it('contains header, kpis, columns and sidebar mount points', () => {
|
||||
for (const s of ['app-header', 'demo-project', 'id="kpiOpen"', 'id="b2Budget"', 'id="b2Feed"']) {
|
||||
expect(html).toContain(s);
|
||||
}
|
||||
});
|
||||
it('keeps the v1 interaction surface (dnd, sse, modals, budget reset)', () => {
|
||||
for (const s of ['draggable', "new EventSource('/events')", 'task-log', 'budgetReset', 'taskModal']) {
|
||||
expect(html).toContain(s);
|
||||
}
|
||||
});
|
||||
it('renders in-progress cards with a progress ring and working glow', () => {
|
||||
expect(html).toContain('b2-ring');
|
||||
expect(html).toContain('b2-working');
|
||||
});
|
||||
it('gives review cards the same ring design (amber variant)', () => {
|
||||
expect(html).toContain('b2-ring-review');
|
||||
});
|
||||
it('sizes card grid rows to content (overlap regression)', () => {
|
||||
expect(html).toContain('grid-auto-rows: max-content');
|
||||
});
|
||||
it('does not ship the old v1 metric cards or lottie kpi icons', () => {
|
||||
expect(html).not.toContain('metric-card');
|
||||
expect(html).not.toContain('lottie');
|
||||
});
|
||||
it('runs the init sequence after all module definitions', () => {
|
||||
const defIdx = html.indexOf('window.__b2UpdateKpis =');
|
||||
const initIdx = html.indexOf('loadStatusMeta();');
|
||||
expect(defIdx).toBeGreaterThan(-1);
|
||||
expect(initIdx).toBeGreaterThan(defIdx);
|
||||
});
|
||||
});
|
||||
66
tests/coreCorrectness.test.ts
Normal file
66
tests/coreCorrectness.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { createTask, claimTask, reviewTask } from '../src/core/services/taskService.js';
|
||||
import { searchMemory } from '../src/core/services/memoryService.js';
|
||||
|
||||
describe('core correctness (TSK-0007)', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-core-'));
|
||||
init(cwd, { projectName: 'core-test', yes: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── FTS5 duplicate-on-update ──────────────────────────────────────────────
|
||||
it('search returns no duplicates after an entity is updated repeatedly', () => {
|
||||
const t = createTask(cwd, { title: 'Zephyr indexing widget', role: 'implementer' });
|
||||
// Each mutation re-upserts into the FTS index.
|
||||
claimTask(cwd, t.id, 'kimi');
|
||||
reviewTask(cwd, t.id);
|
||||
|
||||
const hits = searchMemory(cwd, 'Zephyr');
|
||||
const forTask = hits.filter((r) => r.id === t.id);
|
||||
expect(forTask).toHaveLength(1); // exactly one, not one-per-update
|
||||
});
|
||||
|
||||
// ── claimTask race-guard ──────────────────────────────────────────────────
|
||||
it('only one of two concurrent claims on the same open task wins', () => {
|
||||
const t = createTask(cwd, { title: 'contended', role: 'implementer' });
|
||||
|
||||
const winners: string[] = [];
|
||||
let refused = 0;
|
||||
for (const agent of ['kimi', 'codex']) {
|
||||
try {
|
||||
const claimed = claimTask(cwd, t.id, agent);
|
||||
winners.push(claimed.assignedTo ?? '');
|
||||
} catch {
|
||||
refused += 1;
|
||||
}
|
||||
}
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(refused).toBe(1);
|
||||
expect(winners[0]).toBe('kimi'); // the first to act wins
|
||||
});
|
||||
|
||||
it('a re-claim by the same agent is an idempotent no-op', () => {
|
||||
const t = createTask(cwd, { title: 'mine', role: 'implementer' });
|
||||
claimTask(cwd, t.id, 'kimi');
|
||||
const again = claimTask(cwd, t.id, 'kimi'); // no throw
|
||||
expect(again.status).toBe('in_progress');
|
||||
expect(again.claimedBy).toBe('kimi');
|
||||
});
|
||||
|
||||
it('refuses to claim a task that is not open', () => {
|
||||
const t = createTask(cwd, { title: 'x', role: 'implementer' });
|
||||
claimTask(cwd, t.id, 'kimi');
|
||||
reviewTask(cwd, t.id); // now in review
|
||||
expect(() => claimTask(cwd, t.id, 'codex')).toThrow(/cannot be claimed/i);
|
||||
});
|
||||
});
|
||||
@ -29,4 +29,17 @@ describe('discovery', () => {
|
||||
broadcaster.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('broadcasts the latest server URL without restarting the broadcaster', async () => {
|
||||
const port = 53379;
|
||||
let url = 'http://127.0.0.1:3377';
|
||||
const broadcaster = startDiscoveryBroadcaster(() => url, { port, intervalMs: 50 });
|
||||
try {
|
||||
expect(await discoverServer(1000, port)).toBe(url);
|
||||
url = 'http://127.0.0.1:4477';
|
||||
expect(await discoverServer(1000, port)).toBe(url);
|
||||
} finally {
|
||||
broadcaster.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
43
tests/mdns.test.ts
Normal file
43
tests/mdns.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { startMdnsAdvertise } from '../src/server/mdns.js';
|
||||
import type { Bonjour, Service } from 'bonjour-service';
|
||||
|
||||
describe('mDNS advertise', () => {
|
||||
it('republishes after an IP change and stops the old service first', async () => {
|
||||
let ip = '192.168.1.10';
|
||||
const calls: string[] = [];
|
||||
const published: Array<{ txt?: Record<string, string> }> = [];
|
||||
|
||||
const createBonjour = () =>
|
||||
({
|
||||
publish(opts: { txt?: Record<string, string> }) {
|
||||
calls.push(`publish:${opts.txt?.address ?? ''}`);
|
||||
published.push(opts);
|
||||
return {
|
||||
on: vi.fn(),
|
||||
stop: vi.fn(() => calls.push('stop')),
|
||||
} as unknown as Service;
|
||||
},
|
||||
destroy: vi.fn(() => calls.push('destroy')),
|
||||
}) as unknown as Bonjour;
|
||||
|
||||
const handle = startMdnsAdvertise({
|
||||
port: 3377,
|
||||
pollMs: 25,
|
||||
getIp: () => ip,
|
||||
createBonjour,
|
||||
});
|
||||
|
||||
expect(handle).toBeDefined();
|
||||
expect(published[0]?.txt?.address).toBe('192.168.1.10');
|
||||
|
||||
ip = '192.168.1.44';
|
||||
await new Promise((resolve) => setTimeout(resolve, 70));
|
||||
|
||||
expect(published).toHaveLength(2);
|
||||
expect(published[1]?.txt?.address).toBe('192.168.1.44');
|
||||
expect(calls).toEqual(['publish:192.168.1.10', 'stop', 'destroy', 'publish:192.168.1.44']);
|
||||
|
||||
handle?.stop();
|
||||
});
|
||||
});
|
||||
39
tests/message-cmd.test.ts
Normal file
39
tests/message-cmd.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { messageRead, inboxMarkRead } from '../src/cli/commands/message.js';
|
||||
import { createMessage, listInbox } from '../src/core/services/messageService.js';
|
||||
|
||||
describe('message commands', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-cmd-'));
|
||||
await init(cwd, { yes: true, projectName: 'test' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('messageRead marks one message as read', () => {
|
||||
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done' });
|
||||
messageRead(cwd, msg.id);
|
||||
|
||||
expect(listInbox(cwd, 'claude', { unreadOnly: true })).toHaveLength(0);
|
||||
expect(listInbox(cwd, 'claude')).toMatchObject([{ id: msg.id, status: 'read' }]);
|
||||
});
|
||||
|
||||
it('inboxMarkRead bulk-marks unread alias messages for architect', () => {
|
||||
createMessage(cwd, { from: 'windows-claude', to: 'architect', text: 'ping 1' });
|
||||
createMessage(cwd, { from: 'codex', to: 'claude', text: 'ping 2' });
|
||||
|
||||
expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(2);
|
||||
inboxMarkRead(cwd, { agent: 'architect', unreadOnly: true });
|
||||
|
||||
expect(listInbox(cwd, 'architect', { unreadOnly: true })).toHaveLength(0);
|
||||
expect(listInbox(cwd, 'claude')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
72
tests/message-receipts.test.ts
Normal file
72
tests/message-receipts.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import {
|
||||
createMessage,
|
||||
listInbox,
|
||||
getMessage,
|
||||
markMessageDelivered,
|
||||
markMessageRead,
|
||||
ackMessage,
|
||||
} from '../src/core/services/messageService.js';
|
||||
import { messageReply } from '../src/cli/commands/message.js';
|
||||
|
||||
describe('message read-receipts + replies', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-msg-receipt-'));
|
||||
await init(cwd, { yes: true, projectName: 'test' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('listInbox transitions unread → delivered on fetch (agent-scoped)', () => {
|
||||
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
|
||||
expect(getMessage(cwd, msg.id).message.status).toBe('unread');
|
||||
|
||||
const inbox = listInbox(cwd, 'claude');
|
||||
expect(inbox[0].status).toBe('delivered');
|
||||
// Persisted on disk, not just in the returned row.
|
||||
expect(getMessage(cwd, msg.id).message.status).toBe('delivered');
|
||||
});
|
||||
|
||||
it('markMessageDelivered never downgrades a stronger receipt', () => {
|
||||
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
|
||||
markMessageRead(cwd, msg.id);
|
||||
const after = markMessageDelivered(cwd, msg.id);
|
||||
expect(after.status).toBe('read');
|
||||
});
|
||||
|
||||
it('ackMessage transitions to acked (strongest receipt)', () => {
|
||||
const msg = createMessage(cwd, { from: 'codex', to: 'claude', text: 'hi' });
|
||||
const acked = ackMessage(cwd, msg.id, 'claude');
|
||||
expect(acked.status).toBe('acked');
|
||||
expect(getMessage(cwd, msg.id).message.status).toBe('acked');
|
||||
});
|
||||
|
||||
it('persists replyTo through create → getMessage', () => {
|
||||
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X' });
|
||||
const reply = createMessage(cwd, { from: 'codex', to: 'claude', text: 'done', replyTo: parent.id });
|
||||
expect(reply.replyTo).toBe(parent.id);
|
||||
expect(getMessage(cwd, reply.id).message.replyTo).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('messageReply routes back to the parent sender and inherits its task', () => {
|
||||
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X', taskId: 'TSK-0007' });
|
||||
const reply = messageReply(cwd, parent.id, { from: 'codex', text: 'on it' });
|
||||
expect(reply.to).toBe('claude'); // back to parent.from
|
||||
expect(reply.replyTo).toBe(parent.id);
|
||||
expect(reply.taskId).toBe('TSK-0007'); // inherited
|
||||
});
|
||||
|
||||
it('messageReply --task overrides the inherited task', () => {
|
||||
const parent = createMessage(cwd, { from: 'claude', to: 'codex', text: 'do X', taskId: 'TSK-0007' });
|
||||
const reply = messageReply(cwd, parent.id, { from: 'codex', text: 'on it', taskId: 'TSK-0009' });
|
||||
expect(reply.taskId).toBe('TSK-0009');
|
||||
});
|
||||
});
|
||||
@ -33,6 +33,7 @@ describe('server routes', () => {
|
||||
it('lists tasks via GET /tasks', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks' });
|
||||
expect(res.headers['cache-control']).toContain('no-store');
|
||||
expect(JSON.parse(res.payload)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@ -47,10 +48,18 @@ describe('server routes', () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when claiming without assignedTo', async () => {
|
||||
it('claims manually when setting in_progress without assignedTo', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'manual', claimedBy: 'manual' });
|
||||
});
|
||||
|
||||
it('claims with the existing assignee when setting in_progress without assignedTo', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.payload)).toMatchObject({ status: 'in_progress', assignedTo: 'codex', claimedBy: 'codex' });
|
||||
});
|
||||
|
||||
it('PATCH /tasks/:id → review', async () => {
|
||||
@ -60,6 +69,18 @@ describe('server routes', () => {
|
||||
expect(JSON.parse(res.payload).status).toBe('review');
|
||||
});
|
||||
|
||||
it('a second claim of the same task returns a clean 400 (race guard, board does not 500) — TSK-0007', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const first = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'kimi' } });
|
||||
expect(first.statusCode).toBe(200);
|
||||
// Another agent tries to claim the now in-progress task.
|
||||
const second = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress', assignedTo: 'codex' } });
|
||||
expect(second.statusCode).toBe(400);
|
||||
// The original claim is intact — not clobbered.
|
||||
const still = JSON.parse((await app.inject({ method: 'GET', url: '/tasks/TSK-0001' })).payload) as { task: { claimedBy?: string } };
|
||||
expect(still.task.claimedBy).toBe('kimi');
|
||||
});
|
||||
|
||||
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
||||
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
|
||||
@ -122,13 +143,17 @@ describe('server routes', () => {
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
|
||||
const html = res.payload;
|
||||
expect(html).toContain('<title>AgentHub Board</title>');
|
||||
// All five status columns are present in the static markup.
|
||||
for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
|
||||
expect(html).toContain('<title>agenthub — Board</title>');
|
||||
// The three active lanes are present in the static markup (board v2).
|
||||
for (const col of ['open', 'in_progress', 'review']) {
|
||||
expect(html).toContain(`data-column="${col}"`);
|
||||
}
|
||||
// Board polling stays wired; handoffs/decisions now live on dedicated pages.
|
||||
expect(html).toContain("getJSON('/tasks')");
|
||||
expect(html).toContain("cache: 'no-store'");
|
||||
expect(html).toContain("window.addEventListener('pageshow'");
|
||||
expect(html).toContain("document.addEventListener('visibilitychange'");
|
||||
expect(html).toContain("var agent = assigned || titledAgent || 'manual'");
|
||||
expect(html).toContain('setInterval(refresh');
|
||||
expect(html).toContain('Token Insights');
|
||||
expect(html).toContain('data-budget-mode="session"');
|
||||
@ -242,7 +267,7 @@ describe('server routes', () => {
|
||||
expect(res.payload).toContain('1m');
|
||||
});
|
||||
|
||||
it('serves the activity page with recent activity and done archive', async () => {
|
||||
it('serves the activity page with tasks only — messaging split out to /messages', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
@ -260,8 +285,43 @@ describe('server routes', () => {
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.payload).toContain('Recent Activity');
|
||||
expect(res.payload).toContain('Done Archive');
|
||||
expect(res.payload).toContain('MSG-0001');
|
||||
expect(res.payload).toContain('TSK-0001');
|
||||
// Hard separation: no message rows leak into /activity anymore.
|
||||
expect(res.payload).not.toContain('MSG-0001');
|
||||
expect(res.payload).not.toContain('kind-message');
|
||||
});
|
||||
|
||||
it('serves the /messages conversation view (HTML) with a conversation list', async () => {
|
||||
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'claude', to: 'codex', text: 'Please check the board' } });
|
||||
const res = await app.inject({ method: 'GET', url: '/messages', headers: { accept: 'text/html' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.payload).toContain('Conversations');
|
||||
expect(res.payload).toContain('MSG-0001');
|
||||
expect(res.payload).toContain('Please check the board');
|
||||
// JSON contract unchanged: no accept header → JSON list, not HTML.
|
||||
const json = await app.inject({ method: 'GET', url: '/messages' });
|
||||
expect(() => JSON.parse(json.payload)).not.toThrow();
|
||||
});
|
||||
|
||||
it('read-receipt chain: create → inbox(delivered) → read → ack', async () => {
|
||||
await app.inject({ method: 'POST', url: '/messages', payload: { from: 'codex', to: 'claude', text: 'ping' } });
|
||||
|
||||
// create → unread
|
||||
const listed = JSON.parse((await app.inject({ method: 'GET', url: '/messages' })).payload) as Array<{ id: string; status: string }>;
|
||||
expect(listed[0].status).toBe('unread');
|
||||
|
||||
// inbox fetch → delivered (agent-scoped side effect)
|
||||
const inbox = JSON.parse((await app.inject({ method: 'GET', url: '/messages?agent=claude' })).payload) as Array<{ id: string; status: string }>;
|
||||
expect(inbox[0].status).toBe('delivered');
|
||||
|
||||
// read
|
||||
const read = JSON.parse((await app.inject({ method: 'POST', url: '/messages/MSG-0001/read' })).payload) as { status: string };
|
||||
expect(read.status).toBe('read');
|
||||
|
||||
// ack
|
||||
const acked = JSON.parse((await app.inject({ method: 'POST', url: '/messages/MSG-0001/ack', payload: { by: 'claude' } })).payload) as { status: string };
|
||||
expect(acked.status).toBe('acked');
|
||||
});
|
||||
|
||||
it('GET /handoffs returns fromRole and toRole fields', async () => {
|
||||
|
||||
@ -48,10 +48,27 @@ describe('single source of truth', () => {
|
||||
const port = Number(new URL(server.url).port);
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (msg: string) => logs.push(msg);
|
||||
await serverStart(cwd, { host: '127.0.0.1', port });
|
||||
console.log = original;
|
||||
console.log = (msg: unknown) => logs.push(String(msg));
|
||||
try {
|
||||
await serverStart(cwd, { host: '127.0.0.1', port });
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
expect(logs.some((m) => m.includes('already running'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start LAN advertisers during tests', async () => {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (msg: unknown) => logs.push(String(msg));
|
||||
let isolated: Awaited<ReturnType<typeof startServer>> | undefined;
|
||||
try {
|
||||
isolated = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
await isolated?.app.close();
|
||||
expect(logs.some((m) => m.includes('reachable in a browser'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -637,3 +637,43 @@ describe('watch --await-review', () => {
|
||||
await expect(watching).resolves.toBeUndefined();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// ─── 9. watch --await-message: architect inbox notifier ─────────────────────
|
||||
|
||||
describe('watch --await-message', () => {
|
||||
let cwd: string;
|
||||
let server: Awaited<ReturnType<typeof startServer>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-await-message-'));
|
||||
init(cwd, { projectName: 'await-message', yes: true });
|
||||
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.app.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns immediately when an unread alias message already exists', async () => {
|
||||
await fetch(`${server.url}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: 'windows-claude', to: 'architect', text: 'need review' }),
|
||||
});
|
||||
|
||||
await expect(watchEvents(server.url, { awaitMessage: 'claude' })).resolves.toBeUndefined();
|
||||
}, 4000);
|
||||
|
||||
it('exits when a new message arrives for the watched alias', async () => {
|
||||
const watching = watchEvents(server.url, { awaitMessage: 'architect', newOnly: true });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await fetch(`${server.url}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: 'codex', to: 'claude', text: 'ready' }),
|
||||
});
|
||||
|
||||
await expect(watching).resolves.toBeUndefined();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
105
tests/taskLog-live.test.ts
Normal file
105
tests/taskLog-live.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { buildApp } from '../src/server/index.js';
|
||||
import { init } from '../src/cli/commands/init.js';
|
||||
import { eventBus } from '../src/server/events.js';
|
||||
import { readTaskLog } from '../src/core/services/taskLogService.js';
|
||||
import { remoteClient } from '../src/cli/remoteClient.js';
|
||||
|
||||
describe('task live console (TSK-0074)', () => {
|
||||
let cwd: string;
|
||||
let app: ReturnType<typeof buildApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-'));
|
||||
init(cwd, { projectName: 'tasklog-test', yes: true });
|
||||
app = buildApp(cwd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
eventBus.removeAllListeners('log');
|
||||
eventBus.removeAllListeners('change');
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
||||
|
||||
const logs: Array<{ taskId?: string; text?: string; level?: string }> = [];
|
||||
const changes: unknown[] = [];
|
||||
const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p);
|
||||
const onChange = (e: unknown) => changes.push(e);
|
||||
eventBus.on('log', onLog);
|
||||
eventBus.on('change', onChange);
|
||||
|
||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
||||
|
||||
eventBus.off('log', onLog);
|
||||
eventBus.off('change', onChange);
|
||||
|
||||
// Exactly one task-log SSE event, carrying the task id + a "Claimed by" line.
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].taskId).toBe('TSK-0001');
|
||||
expect(logs[0].text).toContain('Claimed by codex');
|
||||
expect(logs[0].level).toBe('status');
|
||||
// No double-emission on the change channel (one emitChange per PATCH).
|
||||
expect(changes).toHaveLength(1);
|
||||
|
||||
// Persisted to the task's .log file.
|
||||
const persisted = readTaskLog(cwd, 'TSK-0001');
|
||||
expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true);
|
||||
});
|
||||
|
||||
it('logs every status transition (review, done)', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
||||
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
|
||||
|
||||
const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text);
|
||||
expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true);
|
||||
expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true);
|
||||
expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true);
|
||||
});
|
||||
|
||||
it('task detail HTML renders the Live Console with historic lines', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } });
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.payload).toContain('Live Console');
|
||||
expect(res.payload).toContain('wrote failing test');
|
||||
// Live tail wires to the named task-log SSE event, scoped to this task id.
|
||||
expect(res.payload).toContain("addEventListener('task-log'");
|
||||
expect(res.payload).toContain('"TSK-0001"');
|
||||
});
|
||||
|
||||
it('POST /tasks/:id/log returns the stored entry', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string };
|
||||
expect(entry.text).toBe('green: 12 tests');
|
||||
expect(entry.level).toBe('status');
|
||||
expect(entry.ts).toBeTruthy();
|
||||
});
|
||||
|
||||
it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => {
|
||||
vi.restoreAllMocks();
|
||||
const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' };
|
||||
globalThis.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(stored)),
|
||||
} as Response);
|
||||
|
||||
const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' });
|
||||
expect(res.text).toBe('progress');
|
||||
const call = vi.mocked(fetch).mock.calls[0];
|
||||
expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log');
|
||||
expect((call[1] as RequestInit).method).toBe('POST');
|
||||
});
|
||||
});
|
||||
@ -37,6 +37,8 @@ describe('taskService', () => {
|
||||
const claimed = claimTask(cwd, task.id, 'codex');
|
||||
expect(claimed.status).toBe('in_progress');
|
||||
expect(claimed.assignedTo).toBe('codex');
|
||||
expect(claimed.claimedBy).toBe('codex');
|
||||
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
|
||||
const done = doneTask(cwd, task.id);
|
||||
expect(done.status).toBe('done');
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill } from '../src/server/ui-shared.js';
|
||||
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill, appHeader, appHeaderCss, appHeaderOnlyCss } from '../src/server/ui-shared.js';
|
||||
|
||||
describe('ui-shared helpers', () => {
|
||||
it('designTokensCss returns the dark palette variables', () => {
|
||||
@ -48,3 +48,32 @@ describe('ui-shared helpers', () => {
|
||||
expect(js).toContain('claimed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appHeader (v2 shared header)', () => {
|
||||
it('renders logo, project cell, nav links and status mounts', () => {
|
||||
const h = appHeader('demo', 'team');
|
||||
expect(h).toContain('/logo.svg');
|
||||
expect(h).toContain('id="projectName"');
|
||||
expect(h).toContain('b2-proj');
|
||||
expect(h).toContain('id="newTaskBtn"');
|
||||
expect(h).toContain('id="sseStatus"');
|
||||
for (const path of ['/board', '/team', '/activity', '/messages', '/decisions']) {
|
||||
expect(h).toContain(`href="${path}"`);
|
||||
}
|
||||
});
|
||||
it('marks the current page active with aria-current', () => {
|
||||
const team = appHeader('demo', 'team');
|
||||
expect(team).toContain('active" href="/team" aria-current="page"');
|
||||
const board = appHeader('demo', 'board');
|
||||
expect(board).toContain('active" href="/board" aria-current="page"');
|
||||
expect(board).not.toContain('active" href="/team"');
|
||||
});
|
||||
it('escapes the project name', () => {
|
||||
expect(appHeader('<b>x</b>', 'board')).not.toContain('<b>x</b>');
|
||||
});
|
||||
it('appHeaderOnlyCss is a prefix of appHeaderCss', () => {
|
||||
expect(appHeaderCss().startsWith(appHeaderOnlyCss())).toBe(true);
|
||||
expect(appHeaderOnlyCss()).toContain('.app-header');
|
||||
expect(appHeaderCss()).toContain('.modal-backdrop');
|
||||
});
|
||||
});
|
||||
|
||||
@ -39,6 +39,7 @@ describe('agenthub work — immediate claim (local)', () => {
|
||||
describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
||||
let cwd: string;
|
||||
let server: Awaited<ReturnType<typeof startServer>>;
|
||||
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
|
||||
@ -47,7 +48,8 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.app.close();
|
||||
await server.app.close().catch(() => undefined);
|
||||
await nextServer?.app.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@ -77,4 +79,105 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
||||
expect(mine?.status).toBe('in_progress');
|
||||
expect(mine?.assignedTo).toBe('kimi');
|
||||
}, 6000);
|
||||
|
||||
it('reconnects after the SSE server moves and claims a later task', async () => {
|
||||
const workDone = workAgent({
|
||||
serverUrl: server.url,
|
||||
projectCwd: cwd,
|
||||
agent: 'kimi',
|
||||
role: 'implementer',
|
||||
timeoutSec: 6,
|
||||
reconnectBackoffMs: [50, 100],
|
||||
discoverServer: async () => nextServer?.url,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
server.app.server.closeAllConnections?.();
|
||||
await server.app.close();
|
||||
nextServer = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||
|
||||
await fetch(`${nextServer.url}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'kimi: delegated after reconnect', role: 'implementer' }),
|
||||
});
|
||||
|
||||
await workDone;
|
||||
|
||||
const tasks = (await fetch(`${nextServer.url}/tasks`).then((r) => r.json())) as Task[];
|
||||
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi: delegated after reconnect'));
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine?.status).toBe('in_progress');
|
||||
expect(mine?.assignedTo).toBe('kimi');
|
||||
}, 8000);
|
||||
|
||||
// ── TSK-0119: after a review submit the re-armed work loop must stay reachable ──
|
||||
it('wakes on an architect follow-up message instead of going dormant', async () => {
|
||||
// The implementer re-armed `work` after submitting; no task is addressed yet.
|
||||
const workDone = workAgent({
|
||||
serverUrl: server.url,
|
||||
projectCwd: cwd,
|
||||
agent: 'kimi',
|
||||
role: 'implementer',
|
||||
timeoutSec: 4,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await fetch(`${server.url}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: 'claude', to: 'kimi', text: 'quick question about your submission' }),
|
||||
});
|
||||
|
||||
await workDone; // resolves because the message woke the loop (not on timeout)
|
||||
|
||||
// The loop drained + marked the message read — proving it woke on the message,
|
||||
// not that it merely timed out (a timeout would leave it delivered/unread).
|
||||
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
|
||||
const m = inbox.find((x) => x.text.includes('quick question'));
|
||||
expect(m).toBeDefined();
|
||||
expect(m?.status).toBe('read');
|
||||
}, 7000);
|
||||
|
||||
it('wakes and re-claims when a submitted task is reopened', async () => {
|
||||
// Seed a task addressed to kimi, claim it, submit for review.
|
||||
await fetch(`${server.url}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'kimi: build the widget', role: 'implementer' }),
|
||||
});
|
||||
await fetch(`${server.url}/tasks/TSK-0001`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'in_progress', assignedTo: 'kimi' }),
|
||||
});
|
||||
await fetch(`${server.url}/tasks/TSK-0001`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'review' }),
|
||||
});
|
||||
|
||||
// Re-armed work loop: the task is in review, so nothing is addressed/open yet.
|
||||
const workDone = workAgent({
|
||||
serverUrl: server.url,
|
||||
projectCwd: cwd,
|
||||
agent: 'kimi',
|
||||
role: 'implementer',
|
||||
timeoutSec: 4,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
// Architect reopens (send back to implementer).
|
||||
await fetch(`${server.url}/tasks/TSK-0001`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'open' }),
|
||||
});
|
||||
|
||||
await workDone;
|
||||
|
||||
const { task } = getTask(cwd, 'TSK-0001');
|
||||
expect(task.status).toBe('in_progress');
|
||||
expect(task.assignedTo).toBe('kimi');
|
||||
}, 7000);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user