Compare commits
No commits in common. "c9e240b068ff2cf2a732e3ed852c5f60a8a0be01" and "2fb9db15b1797f133a23ad28eac2e664bb6532b5" have entirely different histories.
c9e240b068
...
2fb9db15b1
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,4 +5,3 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
.agenthub/
|
.agenthub/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,10 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 368 B |
@ -1,928 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
# 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",
|
"name": "agenthub",
|
||||||
"version": "0.10.0",
|
"version": "0.9.1",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@ -1,114 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
import { createMessage, listInbox, markMessageRead, ackMessage, getMessage } from '../../core/services/messageService.js';
|
import { createMessage, listInbox } from '../../core/services/messageService.js';
|
||||||
import type { Message } from '../../core/schema.js';
|
|
||||||
|
|
||||||
export function messageSend(
|
export function messageSend(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@ -20,41 +19,3 @@ 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}`);
|
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,6 +1,5 @@
|
|||||||
import { input, select } from '@inquirer/prompts';
|
import { input, select } from '@inquirer/prompts';
|
||||||
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
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';
|
import type { Task } from '../../core/schema.js';
|
||||||
|
|
||||||
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
||||||
@ -70,12 +69,3 @@ export function taskAssign(cwd: string, id: string, agentName: string): void {
|
|||||||
assignTask(cwd, id, agentName);
|
assignTask(cwd, id, agentName);
|
||||||
console.log(`AgentHub: Task assigned ${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,7 +15,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentHubEvent } from '../../server/events.js';
|
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.
|
// Re-export so tests can import type + helpers from one place.
|
||||||
export type { AgentHubEvent } from '../../server/events.js';
|
export type { AgentHubEvent } from '../../server/events.js';
|
||||||
@ -135,36 +134,6 @@ 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.
|
* Connect to the AgentHub server's SSE endpoint and stream events to stdout.
|
||||||
*
|
*
|
||||||
@ -179,7 +148,7 @@ function isMessageFor(event: AgentHubEvent, agent: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function watchEvents(
|
export async function watchEvents(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
options: { once?: boolean; role?: string; awaitReview?: boolean; awaitMessage?: string; newOnly?: boolean } = {},
|
options: { once?: boolean; role?: string; awaitReview?: boolean; newOnly?: boolean } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const url = new URL('/events', serverUrl);
|
const url = new URL('/events', serverUrl);
|
||||||
// Pass role to the server for an additional server-side filter (saves
|
// Pass role to the server for an additional server-side filter (saves
|
||||||
@ -223,14 +192,6 @@ export async function watchEvents(
|
|||||||
return;
|
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) {
|
while (true) {
|
||||||
let done: boolean;
|
let done: boolean;
|
||||||
@ -269,10 +230,6 @@ export async function watchEvents(
|
|||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.awaitMessage && isMessageFor(event, options.awaitMessage)) {
|
|
||||||
await reader.cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { parseSSEBuffer } from './watch.js';
|
import { parseSSEBuffer } from './watch.js';
|
||||||
import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.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
|
* `agenthub work --agent <name> --role <role>` — the auto-claim primitive
|
||||||
@ -15,47 +13,7 @@ import { remoteClient } from '../remoteClient.js';
|
|||||||
* up automatically without a human prompt. Best run in the background so the
|
* up automatically without a human prompt. Best run in the background so the
|
||||||
* wait doesn't tie up the foreground.
|
* wait doesn't tie up the foreground.
|
||||||
*/
|
*/
|
||||||
interface WorkAgentContext extends AgentContext {
|
export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
|
||||||
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);
|
await announceAgent(ctx.serverUrl, ctx.agent, ctx.role);
|
||||||
|
|
||||||
// Already-waiting task?
|
// Already-waiting task?
|
||||||
@ -70,43 +28,22 @@ export async function workAgent(ctx: WorkAgentContext): Promise<void> {
|
|||||||
return;
|
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(
|
console.log(
|
||||||
`AgentHub: waiting for a task or message addressed to ${ctx.agent}…${ctx.timeoutSec ? ` (timeout ${ctx.timeoutSec}s)` : ''}`,
|
`AgentHub: waiting for a task 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);
|
await waitAndClaim(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remainingMs(deadline: number | undefined): number {
|
function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise<void> {
|
||||||
return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now());
|
const serverUrl = ctx.serverUrl as string;
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
return new Promise((resolve) => {
|
||||||
|
const controller = new AbortController();
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let controller: AbortController | undefined;
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
try {
|
try {
|
||||||
controller?.abort();
|
controller.abort();
|
||||||
} catch {
|
} catch {
|
||||||
/* already aborted */
|
/* already aborted */
|
||||||
}
|
}
|
||||||
@ -119,14 +56,10 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
|||||||
finish();
|
finish();
|
||||||
}, ctx.timeoutSec * 1000)
|
}, ctx.timeoutSec * 1000)
|
||||||
: undefined;
|
: 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
|
// 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).
|
// if a task was claimed (so the caller can stop).
|
||||||
const tryClaim = async (): Promise<boolean> => {
|
const tryClaim = async (): Promise<boolean> => {
|
||||||
ctx.serverUrl = serverUrl;
|
|
||||||
const f = await findAddressedOpenTask(ctx);
|
const f = await findAddressedOpenTask(ctx);
|
||||||
if (!f) return false;
|
if (!f) return false;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
@ -135,34 +68,20 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wake on an architect follow-up message (not just tasks): surface it and
|
fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
|
||||||
// stop, so the implementer never sits dormant on a pending question.
|
.then(async (res) => {
|
||||||
const trySurfaceMessages = async (): Promise<boolean> => {
|
if (!res.body) {
|
||||||
ctx.serverUrl = serverUrl;
|
|
||||||
const n = await drainAgentMessages(ctx);
|
|
||||||
if (n === 0) return false;
|
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
finish();
|
finish();
|
||||||
return true;
|
return;
|
||||||
};
|
}
|
||||||
|
// Close the gap: a task may have appeared between the initial check and
|
||||||
const waitLoop = async () => {
|
// this subscription — check once more now that we're listening.
|
||||||
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');
|
|
||||||
|
|
||||||
// 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 tryClaim()) return;
|
||||||
if (await trySurfaceMessages()) return;
|
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
reconnectAttempt = 0;
|
|
||||||
while (!settled) {
|
while (!settled) {
|
||||||
let done: boolean;
|
let done: boolean;
|
||||||
let value: Uint8Array | undefined;
|
let value: Uint8Array | undefined;
|
||||||
@ -180,33 +99,14 @@ function waitAndClaim(ctx: WorkAgentContext): Promise<void> {
|
|||||||
if (events.some((e) => e.type === 'task')) {
|
if (events.some((e) => e.type === 'task')) {
|
||||||
if (await tryClaim()) return;
|
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 (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();
|
finish();
|
||||||
}
|
})
|
||||||
};
|
.catch((err: unknown) => {
|
||||||
|
if (!(err instanceof Error && err.name === 'AbortError')) {
|
||||||
waitLoop().catch((err: unknown) => {
|
|
||||||
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
|
console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
|
|||||||
219
src/cli/index.ts
219
src/cli/index.ts
@ -2,11 +2,10 @@ import { Command } from 'commander';
|
|||||||
import { init } from './commands/init.js';
|
import { init } from './commands/init.js';
|
||||||
import { status } from './commands/status.js';
|
import { status } from './commands/status.js';
|
||||||
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
||||||
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
|
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
|
||||||
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
import { messageSend, inboxList } from './commands/message.js';
|
||||||
import { askCreate, askList, askAnswer, askEscalate, waitForAsk } from './commands/ask.js';
|
|
||||||
import { agentSetup, hookContext } from './commands/agentSetup.js';
|
import { agentSetup, hookContext } from './commands/agentSetup.js';
|
||||||
import { syncOrgFromFile } from '../core/services/orgService.js';
|
import { syncOrgFromFile } from '../core/services/orgService.js';
|
||||||
import { delegate } from './commands/delegate.js';
|
import { delegate } from './commands/delegate.js';
|
||||||
@ -366,23 +365,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
taskReopen(projectCwd, id);
|
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);
|
program.addCommand(taskCmd);
|
||||||
|
|
||||||
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
||||||
@ -475,83 +457,8 @@ export function createProgram(cwd: string): Command {
|
|||||||
program.addCommand(decisionCmd);
|
program.addCommand(decisionCmd);
|
||||||
|
|
||||||
// ─── messaging ───────────────────────────────────────────────────────────
|
// ─── messaging ───────────────────────────────────────────────────────────
|
||||||
const messageCmd = new Command('message')
|
program
|
||||||
.description('Send and manage direct messages')
|
.command('message <to> <text>')
|
||||||
.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')
|
.description('Send a direct message to another agent')
|
||||||
.requiredOption('--from <agent>', 'Sender agent name')
|
.requiredOption('--from <agent>', 'Sender agent name')
|
||||||
.option('--task <id>', 'Related task ID')
|
.option('--task <id>', 'Related task ID')
|
||||||
@ -567,26 +474,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
messageSend(projectCwd, payload);
|
messageSend(projectCwd, payload);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
program.addCommand(messageCmd);
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('inbox')
|
.command('inbox')
|
||||||
.description('Read messages addressed to an agent')
|
.description('Read messages addressed to an agent')
|
||||||
.requiredOption('--agent <agent>', 'Agent whose inbox to read')
|
.requiredOption('--agent <agent>', 'Agent whose inbox to read')
|
||||||
.option('--unread', 'Only unread messages')
|
.option('--unread', 'Only unread messages')
|
||||||
.option('--mark-read', 'Mark listed messages as read')
|
.action(async (options: { agent: string; unread?: boolean }) => {
|
||||||
.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);
|
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) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
|
const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread);
|
||||||
@ -594,107 +489,12 @@ export function createProgram(cwd: string): Command {
|
|||||||
for (const m of msgs) {
|
for (const m of msgs) {
|
||||||
console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`);
|
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 {
|
} else {
|
||||||
if (options.markRead) inboxMarkRead(projectCwd, { agent: options.agent, unreadOnly: !!options.unread });
|
inboxList(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 ──────────────────────────────────────────────────────────
|
// ─── auto-start ──────────────────────────────────────────────────────────
|
||||||
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
const agentCmd = new Command('agent').description('Per-agent machine setup');
|
||||||
agentCmd
|
agentCmd
|
||||||
@ -821,11 +621,10 @@ export function createProgram(cwd: string): Command {
|
|||||||
.requiredOption('--agent <name>', 'Agent name')
|
.requiredOption('--agent <name>', 'Agent name')
|
||||||
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
.option('--role <role>', 'Role (default: implementer)', 'implementer')
|
||||||
.option('--timeout <sec>', 'Stop waiting after N seconds (default: wait indefinitely)')
|
.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) => {
|
.action(async (options) => {
|
||||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
|
const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined;
|
||||||
const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec, unattended: !!options.unattended };
|
const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec };
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, () => workAgent(ctx));
|
await runRemote(serverUrl, () => workAgent(ctx));
|
||||||
} else {
|
} else {
|
||||||
@ -840,8 +639,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)')
|
.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('--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('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier')
|
||||||
.option('--await-message <agent>', 'Exit when an unread message arrives for agent/role; architect message notifier')
|
.option('--new-only', 'With --await-review: fire only on NEW submissions, ignore tasks already in review on connect (re-armable without spinning)')
|
||||||
.option('--new-only', 'With --await-review/--await-message: ignore existing backlog on connect (re-armable without spinning)')
|
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const { serverUrl } = await resolveContext(program, cwd);
|
const { serverUrl } = await resolveContext(program, cwd);
|
||||||
if (!serverUrl) {
|
if (!serverUrl) {
|
||||||
@ -853,7 +651,6 @@ export function createProgram(cwd: string): Command {
|
|||||||
once: options.once as boolean | undefined,
|
once: options.once as boolean | undefined,
|
||||||
role: options.role as string | undefined,
|
role: options.role as string | undefined,
|
||||||
awaitReview: options.awaitReview as boolean | undefined,
|
awaitReview: options.awaitReview as boolean | undefined,
|
||||||
awaitMessage: options.awaitMessage as string | undefined,
|
|
||||||
newOnly: options.newOnly as boolean | undefined,
|
newOnly: options.newOnly as boolean | undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type { Task, Handoff, Decision, Memory, Message, Ask, ActivityItem } from '../core/schema.js';
|
import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
|
||||||
import type { IndexEntry } from '../core/index.js';
|
import type { IndexEntry } from '../core/index.js';
|
||||||
import type { InboxMessage } from '../core/services/messageService.js';
|
import type { InboxMessage } from '../core/services/messageService.js';
|
||||||
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
|
||||||
|
|
||||||
export class RemoteError extends Error {
|
export class RemoteError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@ -93,14 +92,6 @@ export const remoteClient = {
|
|||||||
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
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> {
|
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||||
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
||||||
},
|
},
|
||||||
@ -146,36 +137,6 @@ export const remoteClient = {
|
|||||||
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
|
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 }> {
|
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}`);
|
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,7 +7,6 @@ const prefixes: Record<string, string> = {
|
|||||||
decision: 'DEC',
|
decision: 'DEC',
|
||||||
memory: 'MEM',
|
memory: 'MEM',
|
||||||
message: 'MSG',
|
message: 'MSG',
|
||||||
ask: 'ASK',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CounterType = keyof typeof prefixes;
|
export type CounterType = keyof typeof prefixes;
|
||||||
|
|||||||
@ -14,7 +14,6 @@ export interface IndexEntry {
|
|||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
claimedBy?: string;
|
|
||||||
reviewer?: string;
|
reviewer?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
// Handoff-specific routing fields
|
// Handoff-specific routing fields
|
||||||
@ -47,7 +46,6 @@ export class Index {
|
|||||||
status TEXT,
|
status TEXT,
|
||||||
role TEXT,
|
role TEXT,
|
||||||
assignedTo TEXT,
|
assignedTo TEXT,
|
||||||
claimedBy TEXT,
|
|
||||||
reviewer TEXT,
|
reviewer TEXT,
|
||||||
tags TEXT,
|
tags TEXT,
|
||||||
fromRole TEXT,
|
fromRole TEXT,
|
||||||
@ -64,26 +62,18 @@ export class Index {
|
|||||||
const existingCols = new Set(
|
const existingCols = new Set(
|
||||||
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
|
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
|
||||||
);
|
);
|
||||||
for (const col of ['claimedBy', 'reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
|
for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
|
||||||
if (!existingCols.has(col)) {
|
if (!existingCols.has(col)) {
|
||||||
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
|
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 = {
|
const params = {
|
||||||
status: null,
|
status: null,
|
||||||
role: null,
|
role: null,
|
||||||
assignedTo: null,
|
assignedTo: null,
|
||||||
claimedBy: null,
|
|
||||||
reviewer: null,
|
reviewer: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
fromRole: null,
|
fromRole: null,
|
||||||
@ -96,25 +86,21 @@ export class Index {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const insert = this.db.prepare(`
|
const insert = this.db.prepare(`
|
||||||
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, claimedBy, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
|
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, @claimedBy, @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)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
type=@type, title=@title, content=@content, filePath=@filePath,
|
type=@type, title=@title, content=@content, filePath=@filePath,
|
||||||
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
|
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
|
||||||
assignedTo=@assignedTo, claimedBy=@claimedBy, reviewer=@reviewer, tags=@tags,
|
assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags,
|
||||||
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
|
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
|
||||||
taskId=@taskId, relatedTasks=@relatedTasks
|
taskId=@taskId, relatedTasks=@relatedTasks
|
||||||
`);
|
`);
|
||||||
insert.run(params);
|
insert.run(params);
|
||||||
|
|
||||||
if (fts) {
|
const search = this.db.prepare(`
|
||||||
// The FTS5 `search` table has no UNIQUE key on `id` (it's a plain indexed
|
INSERT OR REPLACE INTO search (id, title, content) VALUES (@id, @title, @content)
|
||||||
// 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
|
search.run(params);
|
||||||
// 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. */
|
/** Permanently drop an entity from both the entities table and the FTS index. */
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { dirname, join, parse } from 'path';
|
import { dirname, join, parse } from 'path';
|
||||||
|
|
||||||
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'asks' | 'status';
|
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'messages' | 'status';
|
||||||
|
|
||||||
export function getAgentHubDir(cwd: string = process.cwd()): string {
|
export function getAgentHubDir(cwd: string = process.cwd()): string {
|
||||||
return join(cwd, '.agenthub');
|
return join(cwd, '.agenthub');
|
||||||
|
|||||||
@ -15,7 +15,6 @@ export const TaskSchema = z.object({
|
|||||||
priority: Priority.default('medium'),
|
priority: Priority.default('medium'),
|
||||||
role: Role.optional(),
|
role: Role.optional(),
|
||||||
assignedTo: z.string().optional(),
|
assignedTo: z.string().optional(),
|
||||||
claimedBy: z.string().optional(),
|
|
||||||
reviewer: z.string().optional(),
|
reviewer: z.string().optional(),
|
||||||
createdAt: z.string().datetime(),
|
createdAt: z.string().datetime(),
|
||||||
updatedAt: z.string().datetime(),
|
updatedAt: z.string().datetime(),
|
||||||
@ -84,40 +83,7 @@ export const MessageSchema = z.object({
|
|||||||
to: z.string().min(1),
|
to: z.string().min(1),
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
taskId: z.string().optional(),
|
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(),
|
createdAt: z.string().datetime(),
|
||||||
updatedAt: z.string().datetime(),
|
updatedAt: z.string().datetime(),
|
||||||
});
|
});
|
||||||
@ -204,6 +170,5 @@ export type Handoff = z.infer<typeof HandoffSchema>;
|
|||||||
export type Decision = z.infer<typeof DecisionSchema>;
|
export type Decision = z.infer<typeof DecisionSchema>;
|
||||||
export type Memory = z.infer<typeof MemorySchema>;
|
export type Memory = z.infer<typeof MemorySchema>;
|
||||||
export type Message = z.infer<typeof MessageSchema>;
|
export type Message = z.infer<typeof MessageSchema>;
|
||||||
export type Ask = z.infer<typeof AskSchema>;
|
|
||||||
export type Status = z.infer<typeof StatusSchema>;
|
export type Status = z.infer<typeof StatusSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
|||||||
let summary: string;
|
let summary: string;
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
summary = task.claimedBy ? `Claimed by ${task.claimedBy}` : task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
|
summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
|
||||||
break;
|
break;
|
||||||
case 'review':
|
case 'review':
|
||||||
summary = 'Submitted for review';
|
summary = 'Submitted for review';
|
||||||
@ -117,7 +117,6 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
|||||||
|
|
||||||
const meta: Record<string, unknown> = { status: task.status };
|
const meta: Record<string, unknown> = { status: task.status };
|
||||||
if (task.assignedTo) meta.assignedTo = task.assignedTo;
|
if (task.assignedTo) meta.assignedTo = task.assignedTo;
|
||||||
if (task.claimedBy) meta.claimedBy = task.claimedBy;
|
|
||||||
if (task.doneBy) meta.by = task.doneBy;
|
if (task.doneBy) meta.by = task.doneBy;
|
||||||
if (task.doneTokens != null) meta.tokens = task.doneTokens;
|
if (task.doneTokens != null) meta.tokens = task.doneTokens;
|
||||||
if (task.doneDuration != null) meta.duration = task.doneDuration;
|
if (task.doneDuration != null) meta.duration = task.doneDuration;
|
||||||
@ -125,7 +124,7 @@ export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
|||||||
items.push({
|
items.push({
|
||||||
at: task.updatedAt,
|
at: task.updatedAt,
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
actor: task.doneBy ?? task.claimedBy ?? task.assignedTo ?? task.role ?? 'unknown',
|
actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown',
|
||||||
summary,
|
summary,
|
||||||
meta,
|
meta,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
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,7 +34,6 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
|
|||||||
to: options.to,
|
to: options.to,
|
||||||
text: options.text,
|
text: options.text,
|
||||||
taskId: options.taskId,
|
taskId: options.taskId,
|
||||||
replyTo: options.replyTo,
|
|
||||||
status: 'unread',
|
status: 'unread',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@ -50,17 +49,6 @@ export function createMessage(cwd: string, options: Partial<Message> = {}): Mess
|
|||||||
return record;
|
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 {
|
export interface InboxMessage {
|
||||||
id: string;
|
id: string;
|
||||||
from: string;
|
from: string;
|
||||||
@ -71,46 +59,23 @@ export interface InboxMessage {
|
|||||||
createdAt: string;
|
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[] {
|
export function listInbox(cwd: string, agent: string, opts: { unreadOnly?: boolean } = {}): InboxMessage[] {
|
||||||
const index = new Index(cwd);
|
const index = new Index(cwd);
|
||||||
const all = index.list('message');
|
const all = index.list('message');
|
||||||
index.close();
|
index.close();
|
||||||
const recipients = messageRecipientAliases(agent);
|
return all
|
||||||
const filtered = all
|
.filter((m) => m.toAgent === agent)
|
||||||
.filter((m) => m.toAgent && recipients.has(String(m.toAgent).toLowerCase()))
|
.filter((m) => !opts.unreadOnly || m.status === 'unread')
|
||||||
.filter((m) => !opts.unreadOnly || m.status === 'unread');
|
.map((m) => ({
|
||||||
|
|
||||||
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,
|
id: m.id,
|
||||||
from: m.fromAgent ?? '',
|
from: m.fromAgent ?? '',
|
||||||
to: m.toAgent ?? '',
|
to: m.toAgent ?? '',
|
||||||
text: m.content,
|
text: m.content,
|
||||||
taskId: m.taskId,
|
taskId: m.taskId,
|
||||||
status,
|
status: m.status ?? 'unread',
|
||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All messages (architect-visible view), newest first. */
|
/** All messages (architect-visible view), newest first. */
|
||||||
@ -136,26 +101,6 @@ export function getMessage(cwd: string, id: string): { message: Message; body: s
|
|||||||
return { message: MessageSchema.parse(frontmatter), body, filePath };
|
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. */
|
/** Mark a message as read. */
|
||||||
export function markMessageRead(cwd: string, id: string): Message {
|
export function markMessageRead(cwd: string, id: string): Message {
|
||||||
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
|
const filePath = join(getEntityDir(cwd, 'messages'), `${id}.md`);
|
||||||
@ -170,22 +115,3 @@ export function markMessageRead(cwd: string, id: string): Message {
|
|||||||
|
|
||||||
return updated;
|
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,7 +17,6 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
|
|||||||
priority: options.priority ?? 'medium',
|
priority: options.priority ?? 'medium',
|
||||||
role: options.role,
|
role: options.role,
|
||||||
assignedTo: options.assignedTo,
|
assignedTo: options.assignedTo,
|
||||||
claimedBy: options.claimedBy,
|
|
||||||
reviewer: options.reviewer,
|
reviewer: options.reviewer,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@ -59,30 +58,8 @@ export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task
|
|||||||
return updated;
|
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 {
|
export function claimTask(cwd: string, id: string, agentName: string): Task {
|
||||||
const { task } = getTask(cwd, id);
|
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
|
||||||
// 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(
|
export function doneTask(
|
||||||
@ -164,7 +141,6 @@ function toIndexEntry(task: Task, filePath: string) {
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
role: task.role,
|
role: task.role,
|
||||||
assignedTo: task.assignedTo,
|
assignedTo: task.assignedTo,
|
||||||
claimedBy: task.claimedBy,
|
|
||||||
reviewer: task.reviewer,
|
reviewer: task.reviewer,
|
||||||
tags: JSON.stringify(task.tags),
|
tags: JSON.stringify(task.tags),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,18 +22,17 @@ export function resolveAdvertiseUrl(host: string, port: number): string {
|
|||||||
return `http://${advertiseHost}:${port}`;
|
return `http://${advertiseHost}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startDiscoveryBroadcaster(getServerUrl: string | (() => string), options?: { port?: number; intervalMs?: number }) {
|
export function startDiscoveryBroadcaster(serverUrl: string, options?: { port?: number; intervalMs?: number }) {
|
||||||
const port = options?.port ?? DISCOVERY_PORT;
|
const port = options?.port ?? DISCOVERY_PORT;
|
||||||
const intervalMs = options?.intervalMs ?? 2000;
|
const intervalMs = options?.intervalMs ?? 2000;
|
||||||
const socket = dgram.createSocket('udp4');
|
const socket = dgram.createSocket('udp4');
|
||||||
const resolveUrl = typeof getServerUrl === 'function' ? getServerUrl : () => getServerUrl;
|
const message = Buffer.from(`${DISCOVERY_PREFIX}${serverUrl}`);
|
||||||
|
|
||||||
socket.on('error', () => {
|
socket.on('error', () => {
|
||||||
// Discovery is best-effort; ignore network errors.
|
// Discovery is best-effort; ignore network errors.
|
||||||
});
|
});
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
const message = Buffer.from(`${DISCOVERY_PREFIX}${resolveUrl()}`);
|
|
||||||
try {
|
try {
|
||||||
socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST);
|
socket.send(message, 0, message.length, port, DISCOVERY_MULTICAST);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -17,14 +17,10 @@ import {
|
|||||||
doneTask,
|
doneTask,
|
||||||
} from '../core/services/taskService.js';
|
} from '../core/services/taskService.js';
|
||||||
import { createHandoff, getHandoff } from '../core/services/handoffService.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 { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
|
||||||
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
||||||
import { createDecision } from '../core/services/decisionService.js';
|
import { createDecision } from '../core/services/decisionService.js';
|
||||||
import { getStatus } from '../core/services/statusService.js';
|
import { getStatus } from '../core/services/statusService.js';
|
||||||
import { discoverServer as discoverHubServer } from '../discovery.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AgentHub MCP server (TSK-0030).
|
* AgentHub MCP server (TSK-0030).
|
||||||
@ -55,57 +51,28 @@ function asText(value: unknown) {
|
|||||||
return { content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
|
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. */
|
/** Block on the SSE stream until findClaim() returns a task, or timeout. */
|
||||||
function waitForTask<T>(
|
function waitForTask<T>(serverUrl: string, findClaim: () => Promise<T | null>, timeoutSec: number): Promise<T | null> {
|
||||||
serverUrl: string,
|
|
||||||
findClaim: (serverUrl: string) => Promise<T | null>,
|
|
||||||
timeoutSec: number,
|
|
||||||
): Promise<T | null> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
const controller = new AbortController();
|
||||||
let settled = false;
|
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) => {
|
const finish = (v: T | null) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
try { controller?.abort(); } catch { /* already */ }
|
try { controller.abort(); } catch { /* already */ }
|
||||||
resolve(v);
|
resolve(v);
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
|
const timer = setTimeout(() => finish(null), Math.max(1, timeoutSec) * 1000);
|
||||||
|
|
||||||
const waitLoop = async () => {
|
fetch(new URL('/events', serverUrl).toString(), { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
|
||||||
while (!settled && remainingMs(deadline) > 0) {
|
.then(async (res) => {
|
||||||
controller = new AbortController();
|
if (!res.body) { clearTimeout(timer); finish(null); return; }
|
||||||
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.
|
// Close the gap: a task may have arrived between the initial check and now.
|
||||||
const early = await findClaim(currentUrl);
|
const early = await findClaim();
|
||||||
if (early) { clearTimeout(timer); finish(early); return; }
|
if (early) { clearTimeout(timer); finish(early); return; }
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
reconnectAttempt = 0;
|
|
||||||
while (!settled) {
|
while (!settled) {
|
||||||
let done: boolean; let value: Uint8Array | undefined;
|
let done: boolean; let value: Uint8Array | undefined;
|
||||||
try { ({ done, value } = await reader.read()); } catch { break; }
|
try { ({ done, value } = await reader.read()); } catch { break; }
|
||||||
@ -113,30 +80,15 @@ function waitForTask<T>(
|
|||||||
if (value) buffer += decoder.decode(value, { stream: true });
|
if (value) buffer += decoder.decode(value, { stream: true });
|
||||||
const { events, remaining } = parseSSEBuffer(buffer);
|
const { events, remaining } = parseSSEBuffer(buffer);
|
||||||
buffer = remaining;
|
buffer = remaining;
|
||||||
// Wake on a new task, a message, or an ask (decision routed to the
|
// Wake on a new task OR a new message addressed to the agent.
|
||||||
// architect / answered back to the asker).
|
if (events.some((e) => e.type === 'task' || e.type === 'message')) {
|
||||||
if (events.some((e) => e.type === 'task' || e.type === 'message' || e.type === 'ask')) {
|
const claimed = await findClaim();
|
||||||
const claimed = await findClaim(currentUrl);
|
|
||||||
if (claimed) { clearTimeout(timer); finish(claimed); return; }
|
if (claimed) { clearTimeout(timer); finish(claimed); return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
clearTimeout(timer); finish(null);
|
||||||
if (settled || (err instanceof Error && err.name === 'AbortError')) return;
|
})
|
||||||
}
|
.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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,82 +106,63 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
|
|
||||||
server.tool('agenthub_work',
|
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.',
|
'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(), unattended: z.boolean().optional() },
|
{ agent: z.string(), role: z.string().optional(), timeoutSec: z.number().optional() },
|
||||||
async ({ agent, role, timeoutSec, unattended }) => {
|
async ({ agent, role, timeoutSec }) => {
|
||||||
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
const ctx: AgentContext = { serverUrl, projectCwd: root, agent, role: role ?? 'implementer' };
|
||||||
const reviewer = isReviewerRole(ctx.role);
|
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
|
// Fetch + mark-read the agent's unread messages, so the work loop surfaces
|
||||||
// them once and doesn't spin on the same message.
|
// them once and doesn't spin on the same message.
|
||||||
const drainInbox = async (nextServerUrl?: string) => {
|
const drainInbox = async () => {
|
||||||
const activeUrl = nextServerUrl ? useServerUrl(nextServerUrl) : ctx.serverUrl;
|
const msgs = remote ? await remoteClient.getInbox(serverUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
||||||
const msgs = remote ? await remoteClient.getInbox(activeUrl!, agent, true) : listInbox(root, agent, { unreadOnly: true });
|
|
||||||
for (const m of msgs) {
|
for (const m of msgs) {
|
||||||
try { if (remote) await remoteClient.markMessageRead(activeUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
|
try { if (remote) await remoteClient.markMessageRead(serverUrl!, m.id); else markMessageRead(root, m.id); } catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
return msgs;
|
return msgs;
|
||||||
};
|
};
|
||||||
const findWork = async (nextServerUrl?: string) => {
|
const findWork = async () => {
|
||||||
if (nextServerUrl) useServerUrl(nextServerUrl);
|
|
||||||
const found = await findAddressedOpenTask(ctx);
|
const found = await findAddressedOpenTask(ctx);
|
||||||
if (found) {
|
if (found) {
|
||||||
if (remote) await remoteClient.claimTask(ctx.serverUrl!, found.task.id, agent);
|
if (remote) await remoteClient.claimTask(serverUrl!, found.task.id, agent);
|
||||||
else claimTask(root, found.task.id, agent);
|
else claimTask(root, found.task.id, agent);
|
||||||
const detail = remote ? await remoteClient.getTask(ctx.serverUrl!, found.task.id) : getTask(root, found.task.id);
|
const detail = remote ? await remoteClient.getTask(serverUrl!, found.task.id) : getTask(root, found.task.id);
|
||||||
const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
|
const hofEntry = found.handoffs.find((h) => h.taskId === found.task.id);
|
||||||
let handoff: unknown = null;
|
let handoff: unknown = null;
|
||||||
if (hofEntry) {
|
if (hofEntry) {
|
||||||
try { handoff = remote ? await remoteClient.getHandoff(ctx.serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
|
try { handoff = remote ? await remoteClient.getHandoff(serverUrl!, hofEntry.id) : getHandoff(root, hofEntry.id); } catch { /* optional */ }
|
||||||
}
|
}
|
||||||
return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox(ctx.serverUrl) };
|
return { claimed: found.task, body: (detail as { body?: string }).body, handoff, messages: await drainInbox() };
|
||||||
}
|
}
|
||||||
const messages = await drainInbox(ctx.serverUrl);
|
const messages = await drainInbox();
|
||||||
if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' };
|
if (messages.length) return { claimed: null, messages, note: 'No task addressed to you, but you have messages — reply with agenthub_message.' };
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
// Architect/reviewer variant: wake on tasks submitted to review (not on
|
// Architect/reviewer variant: wake on tasks submitted to review (not on
|
||||||
// tasks addressed to you). Returns the pending review set — never claims.
|
// tasks addressed to you). Returns the pending review set — never claims.
|
||||||
const listPendingAsks = async (): Promise<Ask[]> =>
|
const findReview = async () => {
|
||||||
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 reviews = await listReviewTasks(ctx);
|
||||||
const messages = await drainInbox(ctx.serverUrl);
|
const messages = await drainInbox();
|
||||||
const asks = await listPendingAsks();
|
if (reviews.length) {
|
||||||
if (reviews.length || asks.length) {
|
|
||||||
return {
|
return {
|
||||||
reviews: reviews.map((r) => ({ id: r.id, title: r.title, assignedTo: r.assignedTo })),
|
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,
|
messages,
|
||||||
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).',
|
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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (messages.length) return { reviews: [], asks: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
|
if (messages.length) return { reviews: [], messages, note: 'Nothing in review, but you have messages — reply with agenthub_message.' };
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const finder: (nextServerUrl?: string) => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork;
|
const finder: () => Promise<Record<string, unknown> | null> = reviewer ? findReview : findWork;
|
||||||
|
|
||||||
// Self-perpetuating loop: every response reminds the agent to relaunch
|
// Self-perpetuating loop: every response reminds the agent to relaunch
|
||||||
// agenthub_work, so a finished task/message never leaves it dormant.
|
// agenthub_work, so a finished task/message never leaves it dormant.
|
||||||
let LOOP = reviewer
|
const LOOP = reviewer
|
||||||
? 'STAY REACHABLE: the instant you finish reviewing, call agenthub_work again — ' +
|
? 'STAY REACHABLE: the instant you finish reviewing, call agenthub_work again — ' +
|
||||||
'run it in the background so the next review submission or ask reaches you in realtime. ' +
|
'run it in the background so the next review submission reaches you in realtime. ' +
|
||||||
'Never end your turn without relaunching agenthub_work.'
|
'Never end your turn without relaunching agenthub_work.'
|
||||||
: 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' +
|
: '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. ' +
|
'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.';
|
'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();
|
const immediate = await finder();
|
||||||
if (immediate) return asText({ ...immediate, loop: LOOP });
|
if (immediate) return asText({ ...immediate, loop: LOOP });
|
||||||
const emptyMsg = reviewer
|
const emptyMsg = reviewer
|
||||||
@ -278,11 +211,6 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
{ id: z.string() },
|
{ id: z.string() },
|
||||||
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
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.',
|
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() },
|
{ 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)));
|
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
|
||||||
@ -317,36 +245,6 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
{ agent: z.string(), unreadOnly: z.boolean().optional() },
|
{ agent: z.string(), unreadOnly: z.boolean().optional() },
|
||||||
async ({ agent, unreadOnly }) => asText(remote ? await remoteClient.getInbox(serverUrl!, agent, unreadOnly) : listInbox(root, agent, { unreadOnly })));
|
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();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
// stdio servers must not write to stdout (it's the protocol channel); log to stderr.
|
// stdio servers must not write to stdout (it's the protocol channel); log to stderr.
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { loadConfig } from '../core/config.js';
|
import { loadConfig } from '../core/config.js';
|
||||||
|
import { listMessages } from '../core/services/messageService.js';
|
||||||
import { listTasks } from '../core/services/taskService.js';
|
import { listTasks } from '../core/services/taskService.js';
|
||||||
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
||||||
import type { IndexEntry } from '../core/index.js';
|
import type { IndexEntry } from '../core/index.js';
|
||||||
@ -35,10 +36,18 @@ function snippet(text: string, max = 120): string {
|
|||||||
export function renderActivityHtml(cwd: string): string {
|
export function renderActivityHtml(cwd: string): string {
|
||||||
const config = loadConfig(cwd);
|
const config = loadConfig(cwd);
|
||||||
const tasks = listTasks(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));
|
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 = [
|
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) => ({
|
...tasks.map((t) => ({
|
||||||
at: t.updatedAt || t.createdAt,
|
at: t.updatedAt || t.createdAt,
|
||||||
html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}">
|
html: `<a class="activity-row" href="/tasks/${encodeURIComponent(t.id)}">
|
||||||
@ -86,8 +95,10 @@ 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 { 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; }
|
.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 { 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); }
|
.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; }
|
.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 { 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:first-child { border-top:0;padding-top:0; }
|
||||||
.task-row:hover,.activity-row:hover { color:var(--text); }
|
.task-row:hover,.activity-row:hover { color:var(--text); }
|
||||||
|
|||||||
1995
src/server/board.ts
Normal file
1995
src/server/board.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
})();`;
|
|
||||||
}
|
|
||||||
@ -1,897 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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 + '%'; });
|
|
||||||
};`;
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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}`;
|
|
||||||
}
|
|
||||||
@ -1,430 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@ -1,335 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'ask' | 'agent';
|
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent';
|
||||||
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
|
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
|
||||||
|
|
||||||
export interface AgentHubEvent {
|
export interface AgentHubEvent {
|
||||||
@ -12,7 +12,6 @@ export interface AgentHubEvent {
|
|||||||
status?: string;
|
status?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
claimedBy?: string;
|
|
||||||
reviewer?: string;
|
reviewer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,6 @@ const WATCHED: { dir: EntityType; type: AgentHubEventType }[] = [
|
|||||||
{ dir: 'decisions', type: 'decision' },
|
{ dir: 'decisions', type: 'decision' },
|
||||||
{ dir: 'memory', type: 'memory' },
|
{ dir: 'memory', type: 'memory' },
|
||||||
{ dir: 'messages', type: 'message' },
|
{ dir: 'messages', type: 'message' },
|
||||||
{ dir: 'asks', type: 'ask' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// fs.watch can fire several events (rename + change) for a single write, and a
|
// fs.watch can fire several events (rename + change) for a single write, and a
|
||||||
@ -61,7 +60,7 @@ function toEvent(
|
|||||||
case 'task':
|
case 'task':
|
||||||
return {
|
return {
|
||||||
stamp,
|
stamp,
|
||||||
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) },
|
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) },
|
||||||
};
|
};
|
||||||
case 'handoff':
|
case 'handoff':
|
||||||
return {
|
return {
|
||||||
@ -74,8 +73,6 @@ function toEvent(
|
|||||||
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
return { stamp, event: { type, action, id, title: str(fm.title) } };
|
||||||
case 'message':
|
case 'message':
|
||||||
return { stamp, event: { type, action, id, title: `${str(fm.from)} → ${str(fm.to)}`, status: str(fm.status), assignedTo: str(fm.to) } };
|
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,16 +11,6 @@ export function buildApp(cwd: string) {
|
|||||||
return app;
|
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 }> {
|
export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> {
|
||||||
const app = buildApp(cwd);
|
const app = buildApp(cwd);
|
||||||
const port = options.port ?? 3377;
|
const port = options.port ?? 3377;
|
||||||
@ -46,12 +36,8 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
|||||||
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${actualPort}`;
|
||||||
console.log(`AgentHub: server listening on ${url}`);
|
console.log(`AgentHub: server listening on ${url}`);
|
||||||
|
|
||||||
// Skip LAN advertisers under test: their background timers (mDNS re-advertise
|
const advertiseUrl = resolveAdvertiseUrl(host, actualPort);
|
||||||
// poll + UDP discovery broadcaster) fire during unrelated tests, flood the
|
broadcaster = startDiscoveryBroadcaster(advertiseUrl);
|
||||||
// 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).
|
// Advertise a browsable LAN hostname over mDNS (best-effort).
|
||||||
mdns = startMdnsAdvertise({ port: actualPort });
|
mdns = startMdnsAdvertise({ port: actualPort });
|
||||||
@ -59,7 +45,6 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
|||||||
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
|
const portSuffix = actualPort === 80 ? '' : `:${actualPort}`;
|
||||||
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
|
console.log(`AgentHub: reachable in a browser at http://${mdns.hostname}${portSuffix}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { app, url };
|
return { app, url };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -70,7 +55,7 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
|||||||
`Stop it, or start with a different --port.`,
|
`Stop it, or start with a different --port.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
console.error(err);
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Bonjour, type Service } from 'bonjour-service';
|
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
|
* Advertise the hub over mDNS/Bonjour as `<name>.local`, so anyone on the LAN
|
||||||
@ -16,80 +15,38 @@ export interface MdnsHandle {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MdnsAdvertiseOptions {
|
export function startMdnsAdvertise(opts: { port: number; name?: string }): MdnsHandle | undefined {
|
||||||
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 base = (opts.name ?? 'agenthub').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'agenthub';
|
||||||
const hostname = `${base}.local`;
|
const hostname = `${base}.local`;
|
||||||
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 {
|
try {
|
||||||
service?.stop?.();
|
const bonjour = new Bonjour();
|
||||||
} catch {
|
const service: Service = bonjour.publish({
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
bonjour?.destroy();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
service = undefined;
|
|
||||||
bonjour = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = () => {
|
|
||||||
bonjour = createBonjour();
|
|
||||||
service = bonjour.publish({
|
|
||||||
name: 'AgentHub',
|
name: 'AgentHub',
|
||||||
type: 'http',
|
type: 'http',
|
||||||
port: opts.port,
|
port: opts.port,
|
||||||
host: hostname,
|
host: hostname,
|
||||||
txt: { path: '/board', address: currentIp ?? '' },
|
txt: { path: '/board' },
|
||||||
});
|
});
|
||||||
// Swallow responder errors — advertisement is optional infrastructure.
|
// Swallow responder errors — advertisement is optional infrastructure.
|
||||||
service.on('error', () => {
|
service.on('error', () => {
|
||||||
/* best-effort */
|
/* 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 {
|
return {
|
||||||
hostname,
|
hostname,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
try {
|
||||||
stopCurrent();
|
service.stop?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
bonjour.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
stopCurrent();
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,221 +0,0 @@
|
|||||||
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,8 +4,7 @@ import { getTaskActivity } from '../core/services/activityService.js';
|
|||||||
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
|
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
|
||||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||||
import { createDecision, listDecisions } from '../core/services/decisionService.js';
|
import { createDecision, listDecisions } from '../core/services/decisionService.js';
|
||||||
import { createMessage, listMessages, listInbox, markMessageRead, ackMessage, getMessage } from '../core/services/messageService.js';
|
import { createMessage, listMessages, listInbox, markMessageRead } 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 { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
|
||||||
import { getStatus, updateStatus } from '../core/services/statusService.js';
|
import { getStatus, updateStatus } from '../core/services/statusService.js';
|
||||||
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
|
||||||
@ -13,15 +12,14 @@ import { computeBudget } from '../core/services/budgetService.js';
|
|||||||
import { getRoster } from '../core/services/rosterService.js';
|
import { getRoster } from '../core/services/rosterService.js';
|
||||||
import { loadConfig, saveConfig } from '../core/config.js';
|
import { loadConfig, saveConfig } from '../core/config.js';
|
||||||
import { renderActivityHtml } from './activity.js';
|
import { renderActivityHtml } from './activity.js';
|
||||||
import { renderBoardHtml } from './board/index.js';
|
import { renderBoardHtml } from './board.js';
|
||||||
import { renderTeamHtml } from './team.js';
|
import { renderTeamHtml } from './team.js';
|
||||||
import { renderArchiveHtml } from './archive.js';
|
import { renderArchiveHtml } from './archive.js';
|
||||||
import { renderDecisionsHtml } from './decisions.js';
|
import { renderDecisionsHtml } from './decisions.js';
|
||||||
import { renderMessagesHtml } from './messages.js';
|
|
||||||
import { renderTaskDetailHtml } from './taskDetail.js';
|
import { renderTaskDetailHtml } from './taskDetail.js';
|
||||||
import { eventBus, emitChange } from './events.js';
|
import { eventBus, emitChange } from './events.js';
|
||||||
import type { AgentHubEvent } from './events.js';
|
import type { AgentHubEvent } from './events.js';
|
||||||
import type { Task, Handoff, Decision, Memory, Message, Ask } from '../core/schema.js';
|
import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { join, extname, normalize } from 'node:path';
|
import { join, extname, normalize } from 'node:path';
|
||||||
@ -46,11 +44,14 @@ function wantsHtml(request: { headers: { accept?: string } }): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
|
||||||
// Default dynamic responses to no-store so board reloads/fetches never reuse
|
// Never let a browser cache a hub HTML page — otherwise a stale board/team
|
||||||
// stale task JSON or HTML. Static asset routes override this with cacheable
|
// page keeps showing old markup and doesn't reflect live task/agent state.
|
||||||
// headers, and the SSE route writes its own raw no-cache header.
|
app.addHook('onSend', async (_request, reply, payload) => {
|
||||||
app.addHook('onRequest', async (_request, reply) => {
|
const ct = reply.getHeader('content-type');
|
||||||
|
if (typeof ct === 'string' && ct.includes('text/html')) {
|
||||||
reply.header('Cache-Control', 'no-store, must-revalidate');
|
reply.header('Cache-Control', 'no-store, must-revalidate');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
// Static, self-contained Trello-like board. Polls /tasks, /handoffs and
|
||||||
@ -59,16 +60,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
const boardHtml = renderBoardHtml(loadConfig(cwd).projectName);
|
||||||
app.get('/board', async (_request, reply) => reply.type('text/html; charset=utf-8').send(boardHtml));
|
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
|
// Serve the KPI-card Lottie animations + the vendored lottie-web player from the
|
||||||
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
|
// repo `assets/` dir. Read-only, path-traversal-guarded, cacheable.
|
||||||
app.get('/card-assets/*', async (request, reply) => {
|
app.get('/card-assets/*', async (request, reply) => {
|
||||||
@ -221,19 +212,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
|
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
|
||||||
app.get('/budget', async () => computeBudget(cwd));
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Tasks ───────────────────────────────────────────────────────────────
|
||||||
app.get('/tasks', async (request) => {
|
app.get('/tasks', async (request) => {
|
||||||
const { status, role } = request.query as { status?: string; role?: string };
|
const { status, role } = request.query as { status?: string; role?: string };
|
||||||
@ -256,7 +234,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
role: task.role,
|
role: task.role,
|
||||||
assignedTo: task.assignedTo,
|
assignedTo: task.assignedTo,
|
||||||
claimedBy: task.claimedBy,
|
|
||||||
reviewer: task.reviewer,
|
reviewer: task.reviewer,
|
||||||
},
|
},
|
||||||
task.updatedAt,
|
task.updatedAt,
|
||||||
@ -326,7 +303,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
||||||
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
||||||
const assigned = assignTask(cwd, id, patch.assignedTo);
|
const assigned = assignTask(cwd, id, patch.assignedTo);
|
||||||
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
|
|
||||||
emitChange(
|
emitChange(
|
||||||
{
|
{
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@ -336,7 +312,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
status: assigned.status,
|
status: assigned.status,
|
||||||
role: assigned.role,
|
role: assigned.role,
|
||||||
assignedTo: assigned.assignedTo,
|
assignedTo: assigned.assignedTo,
|
||||||
claimedBy: assigned.claimedBy,
|
|
||||||
reviewer: assigned.reviewer,
|
reviewer: assigned.reviewer,
|
||||||
},
|
},
|
||||||
assigned.updatedAt,
|
assigned.updatedAt,
|
||||||
@ -347,18 +322,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
let task: Task;
|
let task: Task;
|
||||||
switch (patch.status) {
|
switch (patch.status) {
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
{
|
if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)');
|
||||||
const current = getTask(cwd, id).task;
|
task = claimTask(cwd, id, patch.assignedTo);
|
||||||
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;
|
break;
|
||||||
case 'done':
|
case 'done':
|
||||||
task = doneTask(cwd, id, {
|
task = doneTask(cwd, id, {
|
||||||
@ -380,17 +345,6 @@ 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');
|
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(
|
emitChange(
|
||||||
{
|
{
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@ -400,7 +354,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
role: task.role,
|
role: task.role,
|
||||||
assignedTo: task.assignedTo,
|
assignedTo: task.assignedTo,
|
||||||
claimedBy: task.claimedBy,
|
|
||||||
reviewer: task.reviewer,
|
reviewer: task.reviewer,
|
||||||
},
|
},
|
||||||
task.updatedAt,
|
task.updatedAt,
|
||||||
@ -470,14 +423,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// Direct agent-to-agent / architect-to-agent messages. GET /messages returns
|
// Direct agent-to-agent / architect-to-agent messages. GET /messages returns
|
||||||
// all (architect view); GET /messages/inbox?agent=X&unread=1 returns one
|
// all (architect view); GET /messages/inbox?agent=X&unread=1 returns one
|
||||||
// agent's inbox.
|
// agent's inbox.
|
||||||
app.get('/messages', async (request, reply) => {
|
app.get('/messages', async (request) => {
|
||||||
const { agent, unread, as, c } = request.query as { agent?: string; unread?: string; as?: string; c?: string };
|
const { agent, unread } = request.query as { agent?: string; unread?: string };
|
||||||
// JSON inbox contract (remoteClient.getInbox / agenthub_inbox) — unchanged.
|
|
||||||
if (agent) return listInbox(cwd, agent, { unreadOnly: unread === '1' || unread === 'true' });
|
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);
|
return listMessages(cwd);
|
||||||
});
|
});
|
||||||
app.post('/messages', async (request, reply) => {
|
app.post('/messages', async (request, reply) => {
|
||||||
@ -493,23 +441,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
action: 'created',
|
action: 'created',
|
||||||
id: message.id,
|
id: message.id,
|
||||||
title: `${message.from} → ${message.to}`,
|
title: `${message.from} → ${message.to}`,
|
||||||
assignedTo: message.to,
|
|
||||||
},
|
},
|
||||||
message.updatedAt,
|
message.updatedAt,
|
||||||
);
|
);
|
||||||
return message;
|
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) => {
|
app.post('/messages/:id/read', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
try {
|
try {
|
||||||
@ -525,80 +461,6 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
return badRequest(reply, err instanceof Error ? err.message : 'Message not found');
|
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 ──────────────────────────────────────────────────────────────
|
// ─── Memory ──────────────────────────────────────────────────────────────
|
||||||
app.get('/memory', async () => listMemory(cwd));
|
app.get('/memory', async () => listMemory(cwd));
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { getTask } from '../core/services/taskService.js';
|
|||||||
import { getTaskActivity } from '../core/services/activityService.js';
|
import { getTaskActivity } from '../core/services/activityService.js';
|
||||||
import { getDecision } from '../core/services/decisionService.js';
|
import { getDecision } from '../core/services/decisionService.js';
|
||||||
import { getHandoff, listHandoffs } from '../core/services/handoffService.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 { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
||||||
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
|
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
|
||||||
|
|
||||||
@ -128,13 +127,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
.join('')
|
.join('')
|
||||||
: '<div class="empty">No linked decisions for this task.</div>';
|
: '<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
|
const activityRows = activity.length
|
||||||
? activity
|
? activity
|
||||||
.map(
|
.map(
|
||||||
@ -186,14 +178,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
|
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
|
||||||
.summary { min-width:0;overflow-wrap:anywhere; }
|
.summary { min-width:0;overflow-wrap:anywhere; }
|
||||||
.empty { color:var(--muted); }
|
.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} }
|
@media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -206,10 +190,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
<div class="stats">${taskStats}</div>
|
<div class="stats">${taskStats}</div>
|
||||||
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
|
||||||
<h2>Live Console</h2>
|
|
||||||
<div class="console" id="taskConsole">${consoleRows}</div>
|
|
||||||
</section>
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Handoffs</h2>
|
<h2>Handoffs</h2>
|
||||||
${handoffRows}
|
${handoffRows}
|
||||||
@ -225,26 +205,6 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
</main>
|
</main>
|
||||||
${taskModalHtml()}
|
${taskModalHtml()}
|
||||||
${appHeaderJs()}
|
${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>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -291,74 +291,43 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
|
|||||||
// identically on the non-board pages.
|
// identically on the non-board pages.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type HeaderPage = 'board' | 'team' | 'activity' | 'messages' | 'decisions' | 'archive' | 'task';
|
export type HeaderPage = 'board' | 'team' | 'activity' | '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. */
|
/** CSS for the shared header + new-task modal + toasts. Include once per page. */
|
||||||
export function appHeaderCss(): string {
|
export function appHeaderCss(): string {
|
||||||
return appHeaderOnlyCss() + `
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── New-task modal ─────────────────────────────────────────────────── */
|
/* ── 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 { 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; }
|
.modal-backdrop[hidden] { display: none; }
|
||||||
@ -397,28 +366,34 @@ export function appHeaderCss(): string {
|
|||||||
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`;
|
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The shared header markup (v2): logo, project cell, nav, New-task, live status. */
|
/** The shared header markup: brand, nav, New-task button, live status. */
|
||||||
export function appHeader(projectName: string, current: HeaderPage): string {
|
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) =>
|
const link = (label: string, path: string, key: HeaderPage) =>
|
||||||
`<a class="${current === key ? 'active' : ''}" href="${path}"${current === key ? ' aria-current="page"' : ''}>${label}</a>`;
|
`<a class="nav-link${current === key ? ' active' : ''}" href="${path}">${label}</a>`;
|
||||||
return `
|
return `
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<span class="b2-brand"><img src="/logo.svg" alt="" width="24" height="24" class="b2-logo-img"><b>agenthub</b></span>
|
<div class="brand">
|
||||||
<span class="b2-proj" title="Projektwechsel kommt mit v3">/ <span id="projectName">${escapeHtml(projectName)}</span> <span class="b2-chev">▾</span></span>
|
${mark}
|
||||||
<nav class="b2-nav" aria-label="Primary">
|
<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">
|
||||||
${link('Board', '/board', 'board')}
|
${link('Board', '/board', 'board')}
|
||||||
${link('Team', '/team', 'team')}
|
${link('Team', '/team', 'team')}
|
||||||
${link('Activity', '/activity', 'activity')}
|
${link('Activity', '/activity', 'activity')}
|
||||||
${link('Messages', '/messages', 'messages')}
|
|
||||||
${link('Decisions', '/decisions', 'decisions')}
|
${link('Decisions', '/decisions', 'decisions')}
|
||||||
</nav>
|
</nav>
|
||||||
<span class="b2-hdr-right">
|
<button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||||
<button class="b2-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">+ New task</button>
|
<span class="plus" aria-hidden="true">+</span> New task
|
||||||
|
</button>
|
||||||
<span class="sse-status stale" id="sseStatus">
|
<span class="sse-status stale" id="sseStatus">
|
||||||
<span class="b2-live-dot" aria-hidden="true"></span>
|
<span class="conn-dot" aria-hidden="true"></span>
|
||||||
<span id="sseLabel">connecting</span>
|
<span id="sseLabel">connecting</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</header>`;
|
</header>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,93 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
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,17 +29,4 @@ describe('discovery', () => {
|
|||||||
broadcaster.stop();
|
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
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,7 +33,6 @@ describe('server routes', () => {
|
|||||||
it('lists tasks via GET /tasks', async () => {
|
it('lists tasks via GET /tasks', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
||||||
const res = await app.inject({ method: 'GET', url: '/tasks' });
|
const res = await app.inject({ method: 'GET', url: '/tasks' });
|
||||||
expect(res.headers['cache-control']).toContain('no-store');
|
|
||||||
expect(JSON.parse(res.payload)).toHaveLength(1);
|
expect(JSON.parse(res.payload)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,18 +47,10 @@ describe('server routes', () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claims manually when setting in_progress without assignedTo', async () => {
|
it('returns 400 when claiming without assignedTo', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
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' } });
|
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(400);
|
||||||
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 () => {
|
it('PATCH /tasks/:id → review', async () => {
|
||||||
@ -69,18 +60,6 @@ describe('server routes', () => {
|
|||||||
expect(JSON.parse(res.payload).status).toBe('review');
|
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 () => {
|
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' } });
|
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' } });
|
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
|
||||||
@ -143,17 +122,13 @@ describe('server routes', () => {
|
|||||||
expect(res.headers['content-type']).toContain('text/html');
|
expect(res.headers['content-type']).toContain('text/html');
|
||||||
|
|
||||||
const html = res.payload;
|
const html = res.payload;
|
||||||
expect(html).toContain('<title>agenthub — Board</title>');
|
expect(html).toContain('<title>AgentHub Board</title>');
|
||||||
// The three active lanes are present in the static markup (board v2).
|
// All five status columns are present in the static markup.
|
||||||
for (const col of ['open', 'in_progress', 'review']) {
|
for (const col of ['open', 'in_progress', 'review', 'done', 'cancelled']) {
|
||||||
expect(html).toContain(`data-column="${col}"`);
|
expect(html).toContain(`data-column="${col}"`);
|
||||||
}
|
}
|
||||||
// Board polling stays wired; handoffs/decisions now live on dedicated pages.
|
// Board polling stays wired; handoffs/decisions now live on dedicated pages.
|
||||||
expect(html).toContain("getJSON('/tasks')");
|
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('setInterval(refresh');
|
||||||
expect(html).toContain('Token Insights');
|
expect(html).toContain('Token Insights');
|
||||||
expect(html).toContain('data-budget-mode="session"');
|
expect(html).toContain('data-budget-mode="session"');
|
||||||
@ -267,7 +242,7 @@ describe('server routes', () => {
|
|||||||
expect(res.payload).toContain('1m');
|
expect(res.payload).toContain('1m');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serves the activity page with tasks only — messaging split out to /messages', async () => {
|
it('serves the activity page with recent activity and done archive', async () => {
|
||||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Done item', role: 'implementer' } });
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@ -285,43 +260,8 @@ describe('server routes', () => {
|
|||||||
expect(res.headers['content-type']).toContain('text/html');
|
expect(res.headers['content-type']).toContain('text/html');
|
||||||
expect(res.payload).toContain('Recent Activity');
|
expect(res.payload).toContain('Recent Activity');
|
||||||
expect(res.payload).toContain('Done Archive');
|
expect(res.payload).toContain('Done Archive');
|
||||||
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('MSG-0001');
|
||||||
expect(res.payload).toContain('Please check the board');
|
expect(res.payload).toContain('TSK-0001');
|
||||||
// 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 () => {
|
it('GET /handoffs returns fromRole and toRole fields', async () => {
|
||||||
|
|||||||
@ -48,27 +48,10 @@ describe('single source of truth', () => {
|
|||||||
const port = Number(new URL(server.url).port);
|
const port = Number(new URL(server.url).port);
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
const original = console.log;
|
const original = console.log;
|
||||||
console.log = (msg: unknown) => logs.push(String(msg));
|
console.log = (msg: string) => logs.push(msg);
|
||||||
try {
|
|
||||||
await serverStart(cwd, { host: '127.0.0.1', port });
|
await serverStart(cwd, { host: '127.0.0.1', port });
|
||||||
} finally {
|
|
||||||
console.log = original;
|
console.log = original;
|
||||||
}
|
|
||||||
expect(logs.some((m) => m.includes('already running'))).toBe(true);
|
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,43 +637,3 @@ describe('watch --await-review', () => {
|
|||||||
await expect(watching).resolves.toBeUndefined();
|
await expect(watching).resolves.toBeUndefined();
|
||||||
}, 5000);
|
}, 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
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,8 +37,6 @@ describe('taskService', () => {
|
|||||||
const claimed = claimTask(cwd, task.id, 'codex');
|
const claimed = claimTask(cwd, task.id, 'codex');
|
||||||
expect(claimed.status).toBe('in_progress');
|
expect(claimed.status).toBe('in_progress');
|
||||||
expect(claimed.assignedTo).toBe('codex');
|
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);
|
const done = doneTask(cwd, task.id);
|
||||||
expect(done.status).toBe('done');
|
expect(done.status).toBe('done');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill, appHeader, appHeaderCss, appHeaderOnlyCss } from '../src/server/ui-shared.js';
|
import { agentAvatar, designTokensCss, escapeHtml, liveTimerJs, statusPill } from '../src/server/ui-shared.js';
|
||||||
|
|
||||||
describe('ui-shared helpers', () => {
|
describe('ui-shared helpers', () => {
|
||||||
it('designTokensCss returns the dark palette variables', () => {
|
it('designTokensCss returns the dark palette variables', () => {
|
||||||
@ -48,32 +48,3 @@ describe('ui-shared helpers', () => {
|
|||||||
expect(js).toContain('claimed');
|
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,7 +39,6 @@ describe('agenthub work — immediate claim (local)', () => {
|
|||||||
describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
let server: Awaited<ReturnType<typeof startServer>>;
|
let server: Awaited<ReturnType<typeof startServer>>;
|
||||||
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
|
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
|
||||||
@ -48,8 +47,7 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await server.app.close().catch(() => undefined);
|
await server.app.close();
|
||||||
await nextServer?.app.close().catch(() => undefined);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,105 +77,4 @@ describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
|||||||
expect(mine?.status).toBe('in_progress');
|
expect(mine?.status).toBe('in_progress');
|
||||||
expect(mine?.assignedTo).toBe('kimi');
|
expect(mine?.assignedTo).toBe('kimi');
|
||||||
}, 6000);
|
}, 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