820 lines
49 KiB
Markdown
820 lines
49 KiB
Markdown
# Phase 8 — Operator Experience: Interactive Mailbox, Health Pane, Smart Notifications
|
|
|
|
> Tiếp nối tự nhiên của Phase 7 (UI Optimization). Mục tiêu: biến dashboard từ "viewer" thành "operator console" — actions thực hiện được trực tiếp từ UI, không phải toggle CLI. Path X chosen (Phase 8 = Theme A, Phase 9 = Theme B+C Observability+Reliability deferred).
|
|
|
|
**Open Questions Resolution (Q1-Q6 đã chốt — xem Section 7 chi tiết):**
|
|
- Q1=(b) Có preview compose pane | Q2=(c) Sink JSONL khi `telemetry.enabled` | Q3=(b) Cross-day quiet-hours wrap
|
|
- Q4=(c) Full action menu R/K/D trên health pane | Q5=(c) Confirm chỉ destructive | Q6=(a) ESC discard + confirm-if-long guard
|
|
|
|
## 0. Implementation Status
|
|
|
|
- [x] 8.0 Foundation: keybinding contract + action dispatcher + RunActionResult shape + ConfirmOverlay primitive
|
|
- [x] 8.1.A Mailbox detail overlay (passive list view, no actions yet)
|
|
- [x] 8.1.B Mailbox ack action (hotkey `A` trên message đang chọn)
|
|
- [x] 8.1.C Mailbox nudge action (hotkey `N` + agent picker)
|
|
- [x] 8.1.D Mailbox compose action (hotkey `C` + form overlay) — Q6: ESC discard + confirm-if-long (>50 chars)
|
|
- [x] 8.1.E Mailbox compose preview pane (key `P` toggle, render markdown read-only) — Q1
|
|
- [x] 8.1.F Mailbox ackAll destructive action (hotkey `Shift+X`) — Q5: requires confirm overlay
|
|
- [x] 8.2.A Heartbeat aggregator (`heartbeat-aggregator.ts`)
|
|
- [x] 8.2.B Health pane (pane index `5`) trong dashboard
|
|
- [x] 8.2.C Auto-recovery prompt (stuck worker > N minutes → toast + confirm) — throttled 5min/run
|
|
- [x] 8.2.D Health pane action menu — `R` recovery (foreground only), `K` kill stale workers, `D` diagnostic export — Q4
|
|
- [x] 8.3.A Notification router (severity classifier + dedup window)
|
|
- [x] 8.3.B Notification quiet-hours (cross-day wrap parser) + batching config — Q3
|
|
- [x] 8.3.C Toast badge counter trong widget/powerbar (đếm số notification chưa ack)
|
|
- [x] 8.3.D Notification JSONL sink rotate 7 ngày, gated bởi `telemetry.enabled` — Q2
|
|
- [x] 8.4 Wire `register.ts` + `commands.ts`
|
|
- [x] 8.5 Tests: unit + integration
|
|
|
|
## 1. Roadmap-Level Decisions
|
|
|
|
| # | Decision | Chosen | Rationale |
|
|
|---|---|---|---|
|
|
| D1 | Mailbox actions chạy trực tiếp hay dispatch về team API? | **Dispatch** qua `handleTeamTool({action:"api", config:{operation:...}})` | Tận dụng API hiện có (`ack-message`, `send-message`, `nudge-agent`); zero state-machine duplication; locks/events được giữ nguyên |
|
|
| D2 | Overlay form vs inline edit? | **Overlay form** (modal-like, anchor center) | Dashboard sidebar quá hẹp cho text input; overlay tách biệt focus; ESC dễ cancel |
|
|
| D3 | Health pane là pane mới (`5`) hay tab trong progress? | **Pane mới `5`** | Tránh pollute progress pane; cho user toggle độc lập; consistent với existing 1-4 |
|
|
| D4 | Notification sink: optional opt-in hay default-on? | **Default-on khi `telemetry.enabled !== false`** (Q2=c) | Đồng nhất pattern Phase 6 telemetry; debug-friendly; user opt-out qua telemetry config chung. Path: `<crewRoot>/state/notifications/{YYYY-MM-DD}.jsonl`, rotate 7 ngày |
|
|
| D5 | Quiet-hours format + cross-day? | **HH:MM-HH:MM trong config local timezone, support cross-day wrap** (Q3=b) | Single range `"22:00-07:00"` parser tự nhận diện wrap-around; intuitive vs multi-range array |
|
|
| D6 | Compose-form fields scope? | **Phase 8: from/to/body/taskId + preview pane** (Q1=b) | Preview key `P` toggle render markdown read-only; thread/attachment defer Phase 9 |
|
|
| D7 | Action mới có break keybinding cũ? | **No** — phím mới: `A/N/C/P/Shift+X` (mailbox), `R/K/D` (health), `H/X` (notification); phím hiện hành (`s/u/a/i/d/m/e/o/v/r/p/1-4/k/j`) giữ nguyên (lowercase `r` vẫn = reload root, uppercase `R` = recovery in health pane only) | Backward-compat; context-scoped uppercase |
|
|
| D8 | Mailbox detail panel: inline expand hay separate overlay? | **Separate overlay** (mở khi nhấn Enter trên pane mailbox) | Pane chính giữ nguyên density; overlay scrollable |
|
|
| D9 | Health pane action mode: prompt-only vs full menu? | **Full action menu (Q4=c)**: `R` recovery (foreground-only), `K` kill stale workers, `D` diagnostic export | Operator power-user toolkit; async runs `R/K` disabled with hint; `D` cực hữu ích cho bug report |
|
|
| D10 | Foundation 8.0: tách RunActionDispatcher hay inline? | **Tách module** `src/ui/run-action-dispatcher.ts` | Reuse cho overlay con; dễ test; không bloat dashboard |
|
|
| D11 | Compose ESC behavior? | **Discard + confirm-if-long** (Q6=a) | ESC không lưu draft; nếu body > 50 ký tự → confirm overlay `Y=discard, N=continue editing`; defer draft persistence Phase 9 |
|
|
| D12 | Confirm overlay: per-action ad-hoc hay reusable primitive? | **Reusable primitive** `src/ui/overlays/confirm-overlay.ts` | Q5=c destructive (ackAll/recovery/diagnostic-export-with-secrets) cần consistent UX; reuse cho mọi confirm |
|
|
| D13 | Auto-recovery throttle window? | **5 phút/run/condition-type** | Tránh notification storm khi run dead lâu; `recovery_dead_workers` riêng biệt với `recovery_missing_heartbeat` |
|
|
| D14 | Diagnostic export `D` format & destination? | **JSON + redact secrets** vào `<crewRoot>/artifacts/{runId}/diagnostic-{timestamp}.json` | Self-contained snapshot (manifest + tasks + recent events + heartbeat summary); confirm before write nếu artifact-dir đã có file diag cũ < 1 phút |
|
|
| D15 | Preview pane render scope (Q1=b)? | **Read-only markdown render**: bold/italic/code-block/list — no images/links | Đủ cho operator đọc nội dung trước khi gửi; không cần markdown engine đầy đủ; reuse từ existing transcript-viewer markdown helper nếu có |
|
|
|
|
## 2. Phase Breakdown
|
|
|
|
### Phase 8.0 — Foundation (2 dev-day, +0.5 cho ConfirmOverlay)
|
|
|
|
**File mới:**
|
|
- `src/ui/run-action-dispatcher.ts` — wrapper gọi `handleTeamTool` với `runId` + `operation`, normalize result thành `{ ok, message, data }`.
|
|
- `src/ui/keybinding-map.ts` — central registry mapping `data` (raw stdin) → action name; export `KEY_RESERVED` để overlay con check conflict.
|
|
- `src/ui/overlays/confirm-overlay.ts` — **(Q5)** reusable confirm primitive, anchor center, auto-focus `N` (safe default), Y/Enter=confirm, N/ESC=cancel. ~80 LOC.
|
|
|
|
**Sửa:**
|
|
- `src/ui/run-dashboard.ts` — refactor `handleInput` dùng `keybinding-map`; không thay đổi behavior cũ.
|
|
|
|
**Skeleton:**
|
|
|
|
```ts
|
|
// run-action-dispatcher.ts
|
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
import { handleTeamTool } from "../extension/team-tool.ts";
|
|
|
|
export interface RunActionResult {
|
|
ok: boolean;
|
|
message: string;
|
|
data?: unknown;
|
|
}
|
|
|
|
export async function dispatchMailboxAck(ctx: ExtensionContext, runId: string, messageId: string): Promise<RunActionResult> {
|
|
try {
|
|
const r = await handleTeamTool({ action: "api", runId, config: { operation: "ack-message", messageId } }, ctx);
|
|
return { ok: r.metadata?.status === "ok", message: r.text, data: r };
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
export async function dispatchMailboxNudge(ctx: ExtensionContext, runId: string, agentId: string, message: string): Promise<RunActionResult> { /* ... */ }
|
|
export async function dispatchMailboxCompose(ctx: ExtensionContext, runId: string, payload: { from: string; to: string; body: string; taskId?: string; direction: "inbox" | "outbox" }): Promise<RunActionResult> { /* ... */ }
|
|
export async function dispatchMailboxAckAll(ctx: ExtensionContext, runId: string): Promise<RunActionResult> { /* read-mailbox → loop ack-message */ }
|
|
export async function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Promise<RunActionResult> { /* foreground-interrupt API */ }
|
|
export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> { /* mark dead heartbeats; emit event */ }
|
|
export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: string): Promise<RunActionResult> { /* read-manifest + list-tasks + read-events limit=200 + heartbeat summary → write artifact */ }
|
|
```
|
|
|
|
```ts
|
|
// keybinding-map.ts (Q4 + Q5 expanded)
|
|
export const DASHBOARD_KEYS = {
|
|
close: ["q", "\u001b"],
|
|
select: ["\r", "\n", "s"],
|
|
pane: { agents: ["1"], progress: ["2"], mailbox: ["3"], output: ["4"], health: ["5"] },
|
|
// Mailbox detail overlay context
|
|
mailbox: { ack: ["A"], nudge: ["N"], compose: ["C"], preview: ["P"], ackAll: ["X"], openDetail: ["\r", "\n"] },
|
|
// Health pane context (Q4=c full menu)
|
|
health: { recovery: ["R"], killStale: ["K"], diagnosticExport: ["D"] },
|
|
// Notification context
|
|
notification: { dismissAll: ["H"] }, // 'H' for Hush
|
|
} as const;
|
|
```
|
|
|
|
```ts
|
|
// confirm-overlay.ts
|
|
export interface ConfirmOptions {
|
|
title: string;
|
|
body?: string;
|
|
dangerLevel?: "low" | "medium" | "high"; // colors theme accent
|
|
defaultAction?: "confirm" | "cancel"; // default "cancel"
|
|
}
|
|
export class ConfirmOverlay {
|
|
constructor(private opts: ConfirmOptions, private done: (confirmed: boolean) => void, private theme: unknown) {}
|
|
render(width: number): string[] { /* anchor-center box, dim Y/N hint */ }
|
|
handleInput(data: string): void {
|
|
if (data === "y" || data === "Y" || data === "\r" || data === "\n") return this.done(true);
|
|
if (data === "n" || data === "N" || data === "\u001b" || data === "q") return this.done(false);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Tests:**
|
|
- `test/unit/run-action-dispatcher.test.ts` (7 test cases — 4 mailbox dispatchers + 3 health dispatchers, mock `handleTeamTool`).
|
|
- `test/unit/confirm-overlay.test.ts` (4 cases: render, Y confirms, N cancels, default cancel safety).
|
|
|
|
---
|
|
|
|
### Phase 8.1 — Mailbox Interactivity
|
|
|
|
#### 8.1.A Mailbox detail overlay (1 dev-day)
|
|
|
|
**File mới:**
|
|
- `src/ui/overlays/mailbox-detail-overlay.ts` — class `MailboxDetailOverlay` implement Pi UI custom widget; render 2-column (inbox | outbox); ↑/↓ select; Enter expand body; ESC/q close.
|
|
|
|
**Cập nhật:**
|
|
- `src/ui/dashboard-panes/mailbox-pane.ts` — line cuối cùng đổi từ "use /team-api ..." thành `"Press Enter on mailbox pane to open detail (A=ack, N=nudge, C=compose)"`.
|
|
- `src/ui/run-dashboard.ts` — khi `activePane === "mailbox"` và user nhấn Enter, return `{action: "mailbox-detail"}` thay vì close.
|
|
- `src/extension/registration/commands.ts` — handle `selection.action === "mailbox-detail"` → mở `MailboxDetailOverlay` qua `ctx.ui.custom`.
|
|
|
|
**Skeleton:**
|
|
|
|
```ts
|
|
export class MailboxDetailOverlay {
|
|
private inbox: MailboxMessage[] = [];
|
|
private outbox: MailboxMessage[] = [];
|
|
private selected = 0;
|
|
private side: "inbox" | "outbox" = "inbox";
|
|
constructor(private opts: { runId: string; cwd: string; ctx: ExtensionContext; done: (sel?: MailboxAction) => void; theme: unknown }) {
|
|
this.refresh();
|
|
}
|
|
private refresh(): void { /* read mailbox via team api */ }
|
|
render(width: number): string[] { /* 2-col layout, highlight selected */ }
|
|
handleInput(data: string): void { /* arrow nav, A/N/C dispatch via this.opts.done */ }
|
|
}
|
|
|
|
export interface MailboxAction {
|
|
type: "ack" | "nudge" | "compose" | "reply";
|
|
messageId?: string;
|
|
agentId?: string;
|
|
}
|
|
```
|
|
|
|
**Tests:** `test/unit/mailbox-detail-overlay.test.ts` — 4 cases (render empty, render with items, key navigation, action dispatch).
|
|
|
|
#### 8.1.B Ack action (0.75 dev-day)
|
|
|
|
**Logic:** trong `MailboxDetailOverlay.handleInput`, key `A` (uppercase, để tránh conflict với `a`=artifacts ở dashboard root) → `done({type:"ack", messageId: selectedMessage.id})`.
|
|
|
|
**Update `commands.ts`:** sau khi overlay close, nếu action.type === "ack" → call `dispatchMailboxAck(ctx, runId, action.messageId!)` → toast result.
|
|
|
|
**Acceptance:** ack thành công → mailbox pane re-render với attention count giảm trong < 250ms (snapshot cache invalidate khi `crew.mailbox.acknowledged` event).
|
|
|
|
#### 8.1.C Nudge action (0.75 dev-day)
|
|
|
|
**Logic:** key `N` → mở agent picker overlay (reuse pattern từ existing `LiveRunSidebar`); chọn xong → message input → dispatch `dispatchMailboxNudge`.
|
|
|
|
**File mới:** `src/ui/overlays/agent-picker-overlay.ts` (nhỏ, 80-120 LOC).
|
|
|
|
**Acceptance:** nudge → `crew.mailbox.message` event fire → snapshot invalidate → mailbox pane attention count tăng đúng.
|
|
|
|
#### 8.1.D Compose form (1.25 dev-day)
|
|
|
|
**File mới:** `src/ui/overlays/mailbox-compose-overlay.ts` — form 4 field (from/to/body/taskId), Tab navigation, Enter submit, ESC cancel.
|
|
|
|
**Behavior chi tiết (Q6=a):**
|
|
- Tab/Shift+Tab: cycle giữa các field.
|
|
- Body multi-line: Ctrl+Enter → newline; Enter trên field body với content non-empty → submit.
|
|
- ESC khi body ≤ 50 ký tự → discard immediately, close overlay.
|
|
- ESC khi body > 50 ký tự → mở `ConfirmOverlay` với title `"Discard draft?"` body `"Body has N chars. Y=discard, N=continue editing"`. Cancel default = continue editing (safe).
|
|
- Submit validate: body required (non-whitespace), to required, from default `"operator"` if empty.
|
|
- Direction toggle: Tab vào checkbox `[ ] Send to outbox` → Space toggle.
|
|
|
|
**Logic dispatch:** `dispatchMailboxCompose` với `direction` từ checkbox (default `"inbox"` — operator gửi vào inbox của run).
|
|
|
|
**Tests:** `test/unit/mailbox-compose-overlay.test.ts` — 8 cases (render, tab nav, ESC short discard, ESC long → confirm overlay, confirm overlay cancel = stay editing, confirm overlay confirm = discard, Enter submit, validation empty body, validation empty to).
|
|
|
|
#### 8.1.E Compose preview pane (0.75 dev-day) — Q1=b
|
|
|
|
**File mới:** `src/ui/overlays/mailbox-compose-preview.ts` — read-only render markdown của body field hiện tại; share state với `mailbox-compose-overlay.ts`.
|
|
|
|
**Layout:** compose overlay split horizontal khi preview active — 60% form / 40% preview pane (pane render markdown read-only, không cho focus).
|
|
|
|
**Render scope (D15):** bold (`**`), italic (`*`), code-block (`` ``` ``), inline code (`` ` ``), unordered list (`-`), numbered list (`1.`), heading (`#`/`##`/`###`). Skip images/links (out of scope; render link text only).
|
|
|
|
**Behavior:**
|
|
- Key `P` toggle preview on/off (state in compose overlay).
|
|
- Preview cập nhật real-time khi body thay đổi (debounce 100ms để tránh re-render mỗi keystroke).
|
|
- Khi preview active, header help line update: `"P close preview · Tab cycle · Enter submit · ESC discard"`.
|
|
|
|
**Skeleton:**
|
|
|
|
```ts
|
|
// mailbox-compose-preview.ts
|
|
export function renderComposePreview(body: string, width: number, theme: CrewTheme): string[] {
|
|
const tokens = tokenizeMarkdown(body); // simple tokenizer ~80 LOC
|
|
return tokens.flatMap((t) => renderToken(t, width, theme));
|
|
}
|
|
|
|
function tokenizeMarkdown(body: string): MdToken[] { /* line-by-line scan */ }
|
|
type MdToken = { type: "heading" | "code-block" | "list-item" | "paragraph"; level?: number; text: string };
|
|
```
|
|
|
|
**Tests:** `test/unit/mailbox-compose-preview.test.ts` — 6 cases (plain text, bold/italic, code block, list, heading, mixed content).
|
|
|
|
#### 8.1.F Mailbox ackAll (0.5 dev-day) — Q5=c destructive
|
|
|
|
**Logic:** trong `MailboxDetailOverlay.handleInput`, key `Shift+X` (raw stdin `"X"` uppercase) → mở `ConfirmOverlay`:
|
|
- Title: `"Acknowledge all N unread messages?"`
|
|
- Body: `"This cannot be undone. Y=ack all, N=cancel."`
|
|
- DangerLevel: `"medium"`.
|
|
|
|
Confirm `Y` → `dispatchMailboxAckAll(ctx, runId)` (dispatcher loop ack-message từng id) → toast result `"Acknowledged N messages."`.
|
|
|
|
**Acceptance:** ackAll trong run với 10 unread → all marked acknowledged trong < 2s; mailbox pane attention → 0; emit 10x `crew.mailbox.acknowledged` event.
|
|
|
|
**Tests:** `test/unit/mailbox-detail-overlay.test.ts` thêm 3 cases (Shift+X opens confirm, confirm Y dispatches loop, confirm N stays).
|
|
|
|
---
|
|
|
|
### Phase 8.2 — Health Pane & Recovery
|
|
|
|
#### 8.2.A Heartbeat aggregator (1 dev-day)
|
|
|
|
**File mới:** `src/ui/heartbeat-aggregator.ts`
|
|
|
|
```ts
|
|
export interface HeartbeatSummary {
|
|
runId: string;
|
|
totalTasks: number;
|
|
healthy: number; // alive=true, lastSeenAt < threshold
|
|
stale: number; // lastSeenAt > stale threshold (default 60s)
|
|
dead: number; // lastSeenAt > dead threshold (default 5min) hoặc alive=false
|
|
missing: number; // task running nhưng no heartbeat record
|
|
worstStaleMs: number;
|
|
}
|
|
|
|
export function summarizeHeartbeats(snapshot: RunUiSnapshot, opts?: { staleMs?: number; deadMs?: number; now?: number }): HeartbeatSummary { /* ... */ }
|
|
```
|
|
|
|
**Tests:** `test/unit/heartbeat-aggregator.test.ts` — 6 cases (all healthy, mixed, all dead, missing record, custom threshold, edge `lastSeenAt=now`).
|
|
|
|
#### 8.2.B Health pane (0.75 dev-day)
|
|
|
|
**File mới:** `src/ui/dashboard-panes/health-pane.ts`
|
|
|
|
```ts
|
|
export function renderHealthPane(snapshot: RunUiSnapshot | undefined, opts?: { staleMs?: number; deadMs?: number; isForeground?: boolean }): string[] {
|
|
if (!snapshot) return ["Health pane: snapshot unavailable"];
|
|
const summary = summarizeHeartbeats(snapshot, opts);
|
|
const lines: string[] = [
|
|
`Health: ${summary.healthy}/${summary.totalTasks} healthy · stale=${summary.stale} · dead=${summary.dead} · missing=${summary.missing}`,
|
|
];
|
|
if (summary.worstStaleMs > 0) lines.push(`Worst stale: ${Math.round(summary.worstStaleMs / 1000)}s ago`);
|
|
// Q4=c: show full action menu hint
|
|
const actionHints: string[] = [];
|
|
if ((summary.dead > 0 || summary.missing > 0) && opts?.isForeground !== false) actionHints.push("R recovery");
|
|
if (summary.dead > 0 || summary.stale > 0) actionHints.push("K kill stale");
|
|
actionHints.push("D diagnostic export");
|
|
if (actionHints.length > 0) lines.push(`Actions: ${actionHints.join(" · ")}`);
|
|
if (summary.dead > 0 && opts?.isForeground === false) lines.push("(Async run: R/K disabled — use kill <pid> manually)");
|
|
return lines;
|
|
}
|
|
```
|
|
|
|
**Update `run-dashboard.ts`:**
|
|
- Thêm `"health"` vào type `Pane`.
|
|
- Key `5` → `activePane = "health"`.
|
|
- Switch case render `renderHealthPane` với `isForeground` từ `selectedRun.async ? false : true`.
|
|
- Trong `handleInput`: nếu `activePane === "health"`:
|
|
- `R` → emit `{action: "health-recovery", runId}` (handler sẽ check foreground + ConfirmOverlay).
|
|
- `K` → emit `{action: "health-kill-stale", runId}` (handler ConfirmOverlay if dead > 5).
|
|
- `D` → emit `{action: "health-diagnostic-export", runId}` (handler check existing diag < 1min → confirm overwrite).
|
|
- Header help line update: `"1 agents 2 progress 3 mailbox 4 output 5 health • s/u/a/i actions • R/K/D health"`.
|
|
|
|
**Tests:** `test/unit/health-pane.test.ts` — 6 cases (no snapshot, all healthy → only D hint, dead foreground → R+K+D, dead async → only D + warning, mixed states, foreground false hint visible).
|
|
|
|
#### 8.2.C Auto-recovery toast (0.5 dev-day) — Q4 simplified
|
|
|
|
**Logic:** `RenderScheduler.tick` callback (đã có) gọi `summarizeHeartbeats`; nếu `dead > 0` hoặc `missing > 0` lần đầu → fire toast qua `notification-router` (8.3.A) với severity `"warning"`:
|
|
- Title: `"Run {runId} has {N} dead workers"`.
|
|
- Body: `"Open dashboard → 5 health → R recovery / K kill stale / D diagnostic"`.
|
|
|
|
**Throttle (D13):** dedup id = `recovery_dead_workers_${runId}` — router dedup 5 phút/run/condition-type. Riêng `recovery_missing_heartbeat` có id khác để alert song song nếu cả hai cùng xảy ra.
|
|
|
|
**Tests:** `test/integration/health-recovery.test.ts` — simulate stale heartbeat, verify single toast emitted; emit lần 2 trong window → drop; emit lần 2 sau 5min → fire lại.
|
|
|
|
#### 8.2.D Health action handlers (1.5 dev-day) — Q4=c full menu
|
|
|
|
**Update `src/extension/registration/commands.ts`:** handle 3 new actions từ dashboard:
|
|
|
|
```ts
|
|
// pseudo-code
|
|
if (selection.action === "health-recovery") {
|
|
const run = manifestCache.get(selection.runId);
|
|
if (run?.async) { ctx.ui.notify("Recovery only available for foreground runs.", "warning"); return; }
|
|
const confirmed = await openConfirmOverlay(ctx, { title: "Interrupt foreground run?", body: "Tasks will be marked failed. Y=interrupt, N=cancel.", dangerLevel: "high" });
|
|
if (!confirmed) return;
|
|
const r = await dispatchHealthRecovery(ctx, selection.runId);
|
|
ctx.ui.notify(r.message, r.ok ? "info" : "error");
|
|
}
|
|
|
|
if (selection.action === "health-kill-stale") {
|
|
const summary = summarizeHeartbeats(snapshotCache.get(selection.runId)!);
|
|
if (summary.dead + summary.stale > 5) {
|
|
const confirmed = await openConfirmOverlay(ctx, { title: `Kill ${summary.dead + summary.stale} stale workers?`, dangerLevel: "medium" });
|
|
if (!confirmed) return;
|
|
}
|
|
const r = await dispatchKillStaleWorkers(ctx, selection.runId);
|
|
ctx.ui.notify(r.message, r.ok ? "info" : "error");
|
|
}
|
|
|
|
if (selection.action === "health-diagnostic-export") {
|
|
// D14: check existing diag in last 1min
|
|
const diagDir = path.join(run.artifactsRoot, "diagnostic");
|
|
const recentDiag = listRecentDiagnostic(diagDir, 60_000);
|
|
if (recentDiag) {
|
|
const confirmed = await openConfirmOverlay(ctx, { title: "Recent diagnostic exists", body: `File ${recentDiag} created < 1min ago. Overwrite?`, defaultAction: "cancel" });
|
|
if (!confirmed) return;
|
|
}
|
|
const r = await dispatchDiagnosticExport(ctx, selection.runId);
|
|
ctx.ui.notify(`Diagnostic exported to ${r.data}`, r.ok ? "info" : "error");
|
|
}
|
|
```
|
|
|
|
**File mới:** `src/runtime/diagnostic-export.ts` — collect manifest + tasks + recent events (limit 200) + heartbeat summary + agent status snapshot; redact secrets từ env/config (block list: `*token*`, `*key*`, `*password*`, `*secret*`); write JSON vào `<crewRoot>/artifacts/{runId}/diagnostic-{ISO-timestamp}.json`.
|
|
|
|
**Skeleton:**
|
|
|
|
```ts
|
|
// diagnostic-export.ts
|
|
export interface DiagnosticReport {
|
|
runId: string;
|
|
exportedAt: string;
|
|
manifest: TeamRunManifest;
|
|
tasks: TeamTaskState[];
|
|
recentEvents: TeamEvent[];
|
|
heartbeat: HeartbeatSummary;
|
|
agents: { taskId: string; status: AgentStatus }[];
|
|
envRedacted: Record<string, string>; // env vars with secrets masked as "***"
|
|
}
|
|
|
|
export async function exportDiagnostic(ctx: ExtensionContext, runId: string): Promise<{ path: string; report: DiagnosticReport }> { /* ... */ }
|
|
|
|
function redactSecrets(obj: unknown): unknown { /* recursive replace values where key matches block list */ }
|
|
```
|
|
|
|
**Tests:**
|
|
- `test/unit/diagnostic-export.test.ts` — 5 cases (basic export, secret redaction, missing run errors, file path generation, JSON validity).
|
|
- Smoke: export → open file → verify đầy đủ field + 0 secrets.
|
|
|
|
---
|
|
|
|
### Phase 8.3 — Smart Notifications
|
|
|
|
#### 8.3.A Notification router (1 dev-day)
|
|
|
|
**File mới:** `src/extension/notification-router.ts`
|
|
|
|
```ts
|
|
export type Severity = "info" | "warning" | "error" | "critical";
|
|
|
|
export interface NotificationDescriptor {
|
|
id?: string; // dedup key; nếu cùng id trong window → drop
|
|
severity: Severity;
|
|
source: string; // "run-completed" | "subagent-stuck" | "health" | ...
|
|
runId?: string;
|
|
title: string;
|
|
body?: string;
|
|
timestamp?: number;
|
|
}
|
|
|
|
export interface NotificationRouterOptions {
|
|
dedupWindowMs?: number; // default 30000
|
|
batchWindowMs?: number; // default 0 (no batching by default)
|
|
quietHours?: string; // "22:00-07:00" local
|
|
severityFilter?: Severity[]; // default: ["warning", "error", "critical"]
|
|
sink?: (n: NotificationDescriptor) => void; // optional file/stream sink
|
|
}
|
|
|
|
export class NotificationRouter {
|
|
constructor(private opts: NotificationRouterOptions = {}, private deliver: (n: NotificationDescriptor) => void) {}
|
|
enqueue(n: NotificationDescriptor): void { /* dedup check, severity filter, quiet-hours skip, batch buffer, sink */ }
|
|
flush(): void { /* deliver batched */ }
|
|
dispose(): void { /* clear timers */ }
|
|
}
|
|
```
|
|
|
|
**Wrap `sendFollowUp`:** trong `register.ts`, thay 2 call sites `sendFollowUp(...)` thành `notificationRouter.enqueue({...})`. Router decides có deliver qua `sendFollowUp` hay không.
|
|
|
|
**Tests:** `test/unit/notification-router.test.ts` — 8 cases (dedup, severity filter, quiet hours mock clock, batch, sink invocation, dispose cleanup).
|
|
|
|
#### 8.3.B Quiet-hours + batching config (0.75 dev-day) — Q3=b cross-day wrap
|
|
|
|
**Update `src/schema/config-schema.ts`:**
|
|
```ts
|
|
notifications: Type.Optional(Type.Object({
|
|
enabled: Type.Optional(Type.Boolean()),
|
|
severityFilter: Type.Optional(Type.Array(Type.Union([Type.Literal("info"), Type.Literal("warning"), Type.Literal("error"), Type.Literal("critical")]))),
|
|
dedupWindowMs: Type.Optional(Type.Integer({ minimum: 1000 })),
|
|
batchWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
quietHours: Type.Optional(Type.String({ pattern: "^\\d{2}:\\d{2}-\\d{2}:\\d{2}$" })),
|
|
sinkRetentionDays: Type.Optional(Type.Integer({ minimum: 1, maximum: 90 })), // Q2=c, default 7
|
|
})),
|
|
```
|
|
|
|
**Update `src/config/defaults.ts`:** sane defaults (`severityFilter: ["warning","error","critical"]`, `dedupWindowMs: 30_000`, `batchWindowMs: 0`, `sinkRetentionDays: 7`).
|
|
|
|
**Update `src/config/config.ts`:** parse + merge giống các section khác.
|
|
|
|
**Cross-day parser (Q3=b):** trong `notification-router.ts`, helper isolated cho easy testing:
|
|
|
|
```ts
|
|
// notification-router.ts (excerpt)
|
|
export function parseHHMMRange(range: string): { startMin: number; endMin: number } {
|
|
const [s, e] = range.split("-").map((part) => {
|
|
const [hh, mm] = part.split(":").map(Number);
|
|
return hh * 60 + mm;
|
|
});
|
|
return { startMin: s, endMin: e };
|
|
}
|
|
|
|
export function isInQuietHours(range: string, now: Date = new Date()): boolean {
|
|
const { startMin, endMin } = parseHHMMRange(range);
|
|
const cur = now.getHours() * 60 + now.getMinutes();
|
|
if (startMin === endMin) return false; // empty range
|
|
// Q3=b: cross-day wrap when start > end
|
|
return startMin <= endMin
|
|
? (cur >= startMin && cur < endMin)
|
|
: (cur >= startMin || cur < endMin);
|
|
}
|
|
```
|
|
|
|
**Tests:** `test/unit/notification-router.test.ts` thêm 4 cases parser:
|
|
- `"09:00-17:00"` ở 12:00 → quiet (true).
|
|
- `"09:00-17:00"` ở 22:00 → not quiet (false).
|
|
- `"22:00-07:00"` ở 23:30 → quiet (cross-day true).
|
|
- `"22:00-07:00"` ở 03:00 → quiet (cross-day true).
|
|
- `"22:00-07:00"` ở 12:00 → not quiet (false).
|
|
- Edge: `"00:00-23:59"` ở 12:00 → quiet (always-quiet within day).
|
|
- Edge: `"00:00-00:00"` → always not quiet (empty range).
|
|
|
|
#### 8.3.C Toast badge integration (0.75 dev-day)
|
|
|
|
**Logic:** `NotificationRouter.deliver` → ngoài `sendFollowUp`, cộng `unreadCount++` trong `widgetState.notificationCount`. Reset khi user mở mailbox detail hoặc nhấn `H` (Hush — dismiss-all notifications visible badge).
|
|
|
|
**Update `crew-widget.ts`:** model render thêm `🔔${count}` nếu `count > 0`. Để tránh emoji compatibility issue → fallback `[!${count}]` khi terminal không support emoji (detect qua `process.env.TERM`).
|
|
|
|
**Update `powerbar-publisher.ts`:** segment `pi-crew-active` text append ` 🔔${count}` (hoặc fallback) khi active.
|
|
|
|
**Tests:** `test/unit/widget-notification-badge.test.ts` — 5 cases (no count, count=1, count>9, dismiss reset, terminal fallback).
|
|
|
|
#### 8.3.D Notification JSONL sink (0.5 dev-day) — Q2=c
|
|
|
|
**File mới:** `src/extension/notification-sink.ts`
|
|
|
|
**Logic:** khi config `telemetry.enabled !== false`, NotificationRouter delivery cũng gọi `sink.write(descriptor)`. Sink writes vào `<crewRoot>/state/notifications/{YYYY-MM-DD}.jsonl` (1 file/day, append-only).
|
|
|
|
**Rotation:** start-of-day check (lazy, khi write đầu tiên) → delete files cũ hơn `notifications.sinkRetentionDays` (default 7).
|
|
|
|
**Skeleton:**
|
|
|
|
```ts
|
|
// notification-sink.ts
|
|
export interface NotificationSink {
|
|
write(n: NotificationDescriptor): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
export function createJsonlSink(crewRoot: string, retentionDays: number): NotificationSink {
|
|
const dir = path.join(crewRoot, "state", "notifications");
|
|
let lastRotateDate = "";
|
|
return {
|
|
write(n) {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
if (today !== lastRotateDate) {
|
|
rotateOldFiles(dir, retentionDays);
|
|
lastRotateDate = today;
|
|
}
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.appendFileSync(path.join(dir, `${today}.jsonl`), JSON.stringify({ ...n, timestamp: n.timestamp ?? Date.now() }) + "\n");
|
|
},
|
|
dispose() { /* no-op */ },
|
|
};
|
|
}
|
|
|
|
function rotateOldFiles(dir: string, retentionDays: number): void {
|
|
if (!fs.existsSync(dir)) return;
|
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
for (const file of fs.readdirSync(dir)) {
|
|
if (!file.endsWith(".jsonl")) continue;
|
|
const stat = fs.statSync(path.join(dir, file));
|
|
if (stat.mtimeMs < cutoff) fs.unlinkSync(path.join(dir, file));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Wire trong `register.ts`:** instantiate sink khi `telemetry.enabled !== false`, pass vào `NotificationRouter` options. Dispose trong `cleanupRuntime`.
|
|
|
|
**Tests:** `test/unit/notification-sink.test.ts` — 5 cases (write basic, daily rotation, retention prune, no rotation cùng ngày, telemetry disabled = no-op).
|
|
|
|
---
|
|
|
|
### Phase 8.4 — Wiring (0.75 dev-day)
|
|
|
|
**Update `src/extension/register.ts`:**
|
|
- Instantiate `NotificationRouter` cùng cấp với `runSnapshotCache`; check `loadConfig.telemetry?.enabled !== false` để decide có pass `JsonlSink` không.
|
|
- Pass router vào `subagentManager` callback (line 64-86) thay vì gọi trực tiếp `sendFollowUp`.
|
|
- Pass router vào `RenderScheduler` callback cho 8.2.C auto-recovery alert.
|
|
- Pass `getRunSnapshotCache` + `notificationRouter` vào `commands.ts` deps.
|
|
- Dispose router + sink trong `cleanupRuntime`.
|
|
|
|
**Update `src/extension/registration/commands.ts`:**
|
|
- Handle `selection.action === "mailbox-detail"` → mở `MailboxDetailOverlay`, dispatch action result, toast.
|
|
- Handle `selection.action === "health-recovery" | "health-kill-stale" | "health-diagnostic-export"` (Q4=c) — flow chi tiết 8.2.D.
|
|
- Pass `getRunSnapshotCache` cho overlay (cần để re-render sau action).
|
|
- Pass `confirmOverlayFactory` để các handler reuse `ConfirmOverlay`.
|
|
|
|
---
|
|
|
|
### Phase 8.5 — Tests + Validation (2 dev-day)
|
|
|
|
**Unit (mới ~52 cases):**
|
|
- `run-action-dispatcher.test.ts` (7)
|
|
- `confirm-overlay.test.ts` (4)
|
|
- `mailbox-detail-overlay.test.ts` (7 — bao gồm 3 cases ackAll Shift+X)
|
|
- `mailbox-compose-overlay.test.ts` (8)
|
|
- `mailbox-compose-preview.test.ts` (6) — Q1
|
|
- `agent-picker-overlay.test.ts` (4)
|
|
- `heartbeat-aggregator.test.ts` (6)
|
|
- `health-pane.test.ts` (6) — Q4 expanded
|
|
- `diagnostic-export.test.ts` (5) — Q4
|
|
- `notification-router.test.ts` (8 + 4 quiet-hours parser cases = 12) — Q3
|
|
- `notification-sink.test.ts` (5) — Q2
|
|
- `widget-notification-badge.test.ts` (5)
|
|
|
|
**Integration (mới ~6 cases):**
|
|
- `test/integration/mailbox-action-roundtrip.test.ts` — open dashboard → ack → snapshot invalidate → count giảm.
|
|
- `test/integration/mailbox-ackall-confirm.test.ts` — ackAll trigger ConfirmOverlay → confirm → loop ack 10 messages.
|
|
- `test/integration/notification-dedup.test.ts` — emit cùng event 5 lần trong 30s → 1 toast.
|
|
- `test/integration/notification-quiet-hours.test.ts` — set quietHours `"22:00-07:00"`, mock now=23:30 → 0 toast; mock now=12:00 → 1 toast.
|
|
- `test/integration/notification-sink-rotation.test.ts` — write 8 days → oldest file deleted on day 8.
|
|
- `test/integration/health-recovery-foreground.test.ts` — foreground run dead → R action → ConfirmOverlay → confirm → foreground-interrupt fired.
|
|
- `test/integration/health-diagnostic-export.test.ts` — D action → diagnostic file written với secrets redacted; emit lần 2 trong 1min → ConfirmOverlay overwrite.
|
|
|
|
**Acceptance trước commit:**
|
|
- `npm test` ≥ 351 unit (current 299 + 52), 35 integration (current 29 + 6); 0 fail. Verified current suite: 351 unit + 44 integration.
|
|
- `npm run typecheck` clean.
|
|
- Manual smoke coverage (8 scenarios — mục 6) captured as automated smoke in `test/integration/phase8-smoke.test.ts`.
|
|
|
|
## 3. Wave Organization (parallel-friendly) — Updated với Q1-Q6
|
|
|
|
```
|
|
Wave 1 (parallel, 2.5 days)
|
|
├─ 8.0 Foundation (dispatcher + keybinding-map + ConfirmOverlay)
|
|
├─ 8.3.A NotificationRouter primitive
|
|
└─ 8.2.A Heartbeat aggregator
|
|
|
|
Wave 2 (sequential, 5 days) — depends on Wave 1
|
|
├─ 8.1.A Mailbox detail overlay
|
|
├─ 8.1.B Ack action
|
|
├─ 8.1.C Nudge action
|
|
├─ 8.1.D Compose form (Q6 ESC discard + confirm-if-long)
|
|
├─ 8.1.E Compose preview pane (Q1)
|
|
└─ 8.1.F ackAll Shift+X destructive (Q5)
|
|
|
|
Wave 3 (parallel, 4 days) — depends on Wave 1
|
|
├─ 8.2.B Health pane
|
|
├─ 8.2.C Auto-recovery toast (throttled 5min D13)
|
|
├─ 8.2.D Health action handlers R/K/D (Q4) + diagnostic-export module
|
|
├─ 8.3.B Quiet-hours cross-day parser (Q3) + batching config
|
|
├─ 8.3.C Toast badge widget/powerbar
|
|
└─ 8.3.D JSONL sink + retention (Q2)
|
|
|
|
Wave 4 (sequential, 2.75 days)
|
|
├─ 8.4 Wire register.ts + commands.ts (router, sink, action handlers)
|
|
└─ 8.5 Tests + smoke validation (52 unit + 6 integration mới)
|
|
```
|
|
|
|
**Total estimate: 14-18 dev-days** (vs Phase 7 baseline 18 days). Effort tăng 3.35 day so với plan gốc 11-14d do Q1-Q6 chosen options enrich scope. Phase 8 vẫn smaller hơn Phase 7 vì chủ yếu UI overlay + event router, không động state machine.
|
|
|
|
## 4. Files Affected — Updated với Q1-Q6
|
|
|
|
### New (24 files)
|
|
| Path | Purpose | Est LOC |
|
|
|---|---|---|
|
|
| `src/ui/run-action-dispatcher.ts` | Wrapper team-tool calls (7 dispatchers) | ~140 |
|
|
| `src/ui/keybinding-map.ts` | Key registry (mailbox/health/notification scopes) | ~70 |
|
|
| `src/ui/overlays/confirm-overlay.ts` | **(Q5)** Reusable confirm primitive | ~80 |
|
|
| `src/ui/overlays/mailbox-detail-overlay.ts` | 2-col mailbox view + ackAll | ~250 |
|
|
| `src/ui/overlays/mailbox-compose-overlay.ts` | Compose form + ESC guard | ~210 |
|
|
| `src/ui/overlays/mailbox-compose-preview.ts` | **(Q1)** Markdown preview pane | ~120 |
|
|
| `src/ui/overlays/agent-picker-overlay.ts` | Agent selector | ~110 |
|
|
| `src/ui/heartbeat-aggregator.ts` | Heartbeat summary fn | ~70 |
|
|
| `src/ui/dashboard-panes/health-pane.ts` | Health pane renderer with action hints | ~80 |
|
|
| `src/extension/notification-router.ts` | Router + dedup + quiet-hours parser **(Q3)** | ~220 |
|
|
| `src/extension/notification-sink.ts` | **(Q2)** JSONL sink + retention rotation | ~100 |
|
|
| `src/runtime/diagnostic-export.ts` | **(Q4)** Diagnostic JSON exporter + secret redaction | ~140 |
|
|
| `test/unit/run-action-dispatcher.test.ts` | | ~140 |
|
|
| `test/unit/confirm-overlay.test.ts` | | ~80 |
|
|
| `test/unit/mailbox-detail-overlay.test.ts` | | ~180 |
|
|
| `test/unit/mailbox-compose-overlay.test.ts` | | ~180 |
|
|
| `test/unit/mailbox-compose-preview.test.ts` | **(Q1)** | ~120 |
|
|
| `test/unit/agent-picker-overlay.test.ts` | | ~80 |
|
|
| `test/unit/heartbeat-aggregator.test.ts` | | ~120 |
|
|
| `test/unit/health-pane.test.ts` | Q4 expanded scenarios | ~140 |
|
|
| `test/unit/diagnostic-export.test.ts` | **(Q4)** | ~110 |
|
|
| `test/unit/notification-router.test.ts` | + 4 cross-day parser cases | ~260 |
|
|
| `test/unit/notification-sink.test.ts` | **(Q2)** | ~100 |
|
|
| `test/unit/widget-notification-badge.test.ts` | | ~80 |
|
|
| `test/integration/mailbox-action-roundtrip.test.ts` | | ~120 |
|
|
| `test/integration/mailbox-ackall-confirm.test.ts` | **(Q5)** | ~100 |
|
|
| `test/integration/notification-dedup.test.ts` | | ~90 |
|
|
| `test/integration/notification-quiet-hours.test.ts` | **(Q3)** mock clock | ~110 |
|
|
| `test/integration/notification-sink-rotation.test.ts` | **(Q2)** | ~110 |
|
|
| `test/integration/health-recovery-foreground.test.ts` | **(Q4)** | ~120 |
|
|
| `test/integration/health-diagnostic-export.test.ts` | **(Q4)** | ~120 |
|
|
|
|
### Modified (10 files)
|
|
| Path | Change |
|
|
|---|---|
|
|
| `src/ui/run-dashboard.ts` | Refactor `handleInput` dùng keybinding-map; thêm pane "health" key `5`; help line; emit `health-recovery/health-kill-stale/health-diagnostic-export` actions (Q4) |
|
|
| `src/ui/dashboard-panes/mailbox-pane.ts` | Update help text gợi ý A/N/C/Enter/Shift+X (ackAll) |
|
|
| `src/ui/crew-widget.ts` | Render notification badge `🔔N` (fallback `[!N]` cho terminal không support emoji) |
|
|
| `src/ui/powerbar-publisher.ts` | Append badge cho `pi-crew-active` segment |
|
|
| `src/extension/register.ts` | Instantiate NotificationRouter + JsonlSink (gated bởi telemetry); wrap `sendFollowUp`; pass vào RenderScheduler + commands deps |
|
|
| `src/extension/registration/commands.ts` | Handle `mailbox-detail` + 3 health actions (Q4); mở overlay; reuse ConfirmOverlay (Q5) |
|
|
| `src/extension/team-tool/api.ts` | (no change) — dispatchers reuse existing operations |
|
|
| `src/schema/config-schema.ts` | Thêm `notifications` section + `sinkRetentionDays` (Q2) |
|
|
| `src/config/{config.ts,defaults.ts}` | Parse + default cho notifications (severityFilter, dedupWindowMs, batchWindowMs, quietHours, sinkRetentionDays) |
|
|
| `package.json` | Bump version `0.1.33` → `0.1.34` |
|
|
|
|
### Docs (chỉ update khi user yêu cầu, theo project rule)
|
|
- `docs/architecture.md` — bổ sung mục "Operator Actions", "Notification Router", "Diagnostic Export".
|
|
|
|
## 5. Risk Assessment — Updated với Q1-Q6
|
|
|
|
| Risk | Likelihood | Impact | Mitigation |
|
|
|---|---|---|---|
|
|
| Overlay hijack stdin của Pi UI | Med | High | Reuse pattern `LiveRunSidebar` (đã hoạt động); test với `pi-ui-compat.ts` shim |
|
|
| Keybinding conflict với Pi global hotkeys | Low | Med | Uppercase `A/N/C/P/H/X` (mailbox), `R/K/D` (health) — context-scoped; lowercase Pi defaults không đụng |
|
|
| Notification spam khi nhiều run concurrent | Med | Low-Med | Dedup window 30s default; severity filter excludes "info"; quiet-hours wrap (Q3) |
|
|
| Quiet-hours cross-day parser bug | Low | Med | Q3=b: 7 unit test cases bao gồm cross-midnight; mock clock pattern |
|
|
| MailboxDetailOverlay re-render slow | Low | Low | Reuse signature pattern từ `RunDashboard`; cache lines |
|
|
| Race khi ack trong khi snapshot đang refresh | Low | Med | Dispatch awaits then invalidate cache; render scheduler debounce 75ms |
|
|
| `sendFollowUp` swap break existing flow | Low | High | Wrap không thay; router default-on chỉ khi `notifications.enabled !== false`; fallback gọi `sendFollowUp` raw nếu router throws |
|
|
| Config schema breaking change | Low | High | New section `notifications` purely optional; missing → defaults |
|
|
| **(Q1)** Compose preview pane re-render bottleneck (debounce miss) | Low | Low | Debounce 100ms; cache last rendered tokens; tokenizer < 1ms cho 5KB body |
|
|
| **(Q1)** Markdown tokenizer edge case (nested code in list) | Med | Low | Reuse pattern parser nếu có; 6 unit test edge cases; preview "best-effort" |
|
|
| **(Q2)** Sink disk full / write fail | Low | Low | `appendFileSync` swallow errors qua `logInternalError`; sink failure không crash router |
|
|
| **(Q2)** Retention prune deletes file đang được tail | Low | Low | Chỉ prune `.jsonl` cũ hơn cutoff; daily rotation đảm bảo file hôm nay không bị touch |
|
|
| **(Q2)** PII trong notification body leak vào sink | Med | Med | Sink reuse secret redactor từ `diagnostic-export.ts` (Q4); router tag PII fields nếu cần |
|
|
| **(Q4)** `R` recovery accidentally interrupt healthy run | Low | High | ConfirmOverlay với `dangerLevel: "high"` + default cancel; foreground-only check; clear "tasks marked failed" warning |
|
|
| **(Q4)** `K` kill stale workers race với worker self-recovery | Low | Med | Mark dead heartbeats first → emit event → giải phóng claims; worker tự detect token mismatch sẽ exit |
|
|
| **(Q4)** Diagnostic export ghi đè artifact dir đang dùng | Low | Med | D14: check existing diag < 1min → ConfirmOverlay overwrite; timestamp suffix unique |
|
|
| **(Q4)** Diagnostic secret redaction miss key pattern mới | Med | High | Block list: `*token*`, `*key*`, `*password*`, `*secret*`, `*credential*`, `*auth*`; review qua test fixture với 20 key patterns |
|
|
| **(Q5)** ConfirmOverlay default `Y` accidentally confirms destructive | Low | High | Default action = "cancel"; first focus là `[N]`; ESC = cancel; UI hint underlined N |
|
|
| **(Q6)** ESC discard confirm fatigue (user complain phải confirm mỗi ESC) | Low | Low | Threshold 50 ký tự (configurable nếu user feedback); short body → discard ngay |
|
|
| **(Q6)** Body multi-line Ctrl+Enter not detected on Windows | Med | Low | Test với `pi-ui-compat.ts`; fallback `Alt+Enter` if Ctrl+Enter fails detection |
|
|
|
|
## 6. Testing Strategy — Updated với Q1-Q6
|
|
|
|
**Unit-level (Wave 1-3):**
|
|
- Mock `handleTeamTool` → assert dispatcher returns đúng `{ok, message}` cho 7 dispatchers.
|
|
- Render overlay với fixture snapshot → assert lines layout.
|
|
- Heartbeat aggregator: parameterized test với fixture timestamps (6 cases).
|
|
- Health pane: 6 cases bao phủ foreground/async/healthy/dead/stale variations (Q4).
|
|
- Notification router: mock clock (`globalThis.Date.now` override theo pattern Phase 7); 8 base cases + 4 cross-day parser (Q3).
|
|
- Sink: rotation, retention, telemetry-disabled no-op (Q2).
|
|
- Diagnostic export: secret redaction với 20-key fixture; JSON schema validate (Q4).
|
|
- Confirm overlay: 4 cases verify default-cancel safety (Q5).
|
|
- Compose preview: 6 cases markdown render (Q1).
|
|
|
|
**Integration (Wave 4) — 7 scenarios:**
|
|
- `mailbox-action-roundtrip.test.ts`: open dashboard → ack → snapshot invalidate → count giảm.
|
|
- `mailbox-ackall-confirm.test.ts` (Q5): Shift+X → ConfirmOverlay → confirm → loop ack 10 messages → all `acknowledged`.
|
|
- `notification-dedup.test.ts`: emit 5x cùng `crew.run.failed` trong 30s → `sendFollowUp` mock called once.
|
|
- `notification-quiet-hours.test.ts` (Q3): quiet `"22:00-07:00"` mock now=23:30 → 0 toast; mock now=12:00 → 1 toast.
|
|
- `notification-sink-rotation.test.ts` (Q2): write 8 ngày fake mtime → oldest deleted on day 8.
|
|
- `health-recovery-foreground.test.ts` (Q4): foreground run với 2 dead workers → R action → ConfirmOverlay confirm → `foreground-interrupt` API called → tasks marked failed.
|
|
- `health-diagnostic-export.test.ts` (Q4): D action → file written với 0 secrets in JSON; emit lần 2 trong 1min → ConfirmOverlay overwrite.
|
|
|
|
**Smoke manual (8 scenarios):**
|
|
1. Chạy `team run` 1 task foreground → mở `/team-dashboard` → key `3` mailbox → Enter → key `N` nudge → verify `events.jsonl` có `agent.nudged`.
|
|
2. Chạy 2 run, đợi xong → verify nhận 1-2 toast (dedup).
|
|
3. Set `notifications.quietHours = "00:00-23:59"` → verify 0 toast.
|
|
4. **(Q1)** Compose form, gõ markdown body với bold/list/code → key `P` preview → verify render đúng.
|
|
5. **(Q5)** ackAll trên run với 5 unread → ConfirmOverlay xuất hiện → N cancel → 0 message acked.
|
|
6. **(Q4)** Foreground run với worker stuck > 1min → key `5` health → key `R` → ConfirmOverlay → Y → tasks failed; key `D` → diagnostic file viết.
|
|
7. **(Q2)** Disable telemetry → run + emit notification → verify `<crewRoot>/state/notifications/` không tồn tại.
|
|
8. **(Q6)** Compose body 100 chars → ESC → ConfirmOverlay xuất hiện → N → vẫn editing.
|
|
|
|
**Performance budget:**
|
|
- Mailbox overlay first render < 50ms với 100 messages.
|
|
- Compose preview render < 30ms với 5KB markdown body (Q1).
|
|
- Notification router enqueue overhead < 1ms.
|
|
- Sink write < 5ms (single append) (Q2).
|
|
- Health pane render < 5ms cho 50 tasks.
|
|
- Diagnostic export complete < 200ms cho run với 50 tasks + 200 events (Q4).
|
|
|
|
## 7. Open Questions — RESOLVED (Path X chosen)
|
|
|
|
| Q | Câu hỏi | Lựa chọn | Implementation reference |
|
|
|---|---|---|---|
|
|
| **Q1** | Compose form có cần preview render trước khi submit? | **(b) Có preview pane** | 8.1.E `mailbox-compose-preview.ts`, key `P` toggle, render markdown read-only (bold/italic/code/list/heading), debounce 100ms. D15. +0.75d |
|
|
| **Q2** | Notification sink default ghi `<crewRoot>/state/notifications.jsonl`? | **(c) Sink khi `telemetry.enabled !== false`** | 8.3.D `notification-sink.ts`, JSONL `<crewRoot>/state/notifications/{YYYY-MM-DD}.jsonl`, rotate `sinkRetentionDays` default 7. D4. +0.5d |
|
|
| **Q3** | Quiet-hours cross-day wrap? | **(b) Wrap parser** | 8.3.B `parseHHMMRange` + `isInQuietHours` cross-day logic; 7 unit cases bao gồm `"22:00-07:00"`. D5. +0.25d |
|
|
| **Q4** | Health pane recovery action button inline? | **(c) Full action menu R/K/D** | 8.2.D `R` recovery (foreground-only), `K` kill stale workers, `D` diagnostic export với secret redaction; 3 confirm flows. D9, D14. +1.5d |
|
|
| **Q5** | Ack/nudge confirm cho destructive? | **(c) Confirm chỉ destructive (ackAll/recovery/diag-overwrite)** | 8.0 `ConfirmOverlay` reusable primitive; 8.1.F ackAll Shift+X with confirm. D12. +0.25d |
|
|
| **Q6** | Compose form persist draft khi ESC? | **(a) ESC discard + confirm-if-long** | 8.1.D ESC behavior: body ≤ 50 chars → discard; > 50 → ConfirmOverlay. Defer draft persistence Phase 9. D11. +0.1d |
|
|
|
|
**Tổng effort delta từ Q1-Q6: ~3.35 dev-day** → bump từ 11-14d → 14-18d.
|
|
|
|
**Mục tiêu Q1-Q6 đã đạt:** mọi quyết định scope-shaping đã chốt; team có thể start Wave 1 mà không bị blocked clarification giữa chừng.
|
|
|
|
## 8. Dependencies & Sequencing
|
|
|
|
```
|
|
Phase 7 (DONE) ─────► Phase 8.0 Foundation
|
|
│
|
|
┌──────────┼──────────┐
|
|
▼ ▼ ▼
|
|
8.1 Mailbox 8.2 Health 8.3 Notif
|
|
│ │ │
|
|
└──────────┼──────────┘
|
|
▼
|
|
8.4 Wiring
|
|
│
|
|
▼
|
|
8.5 Tests
|
|
```
|
|
|
|
**Hard prerequisites Phase 7:** ✅ `RunSnapshotCache`, `RenderScheduler`, dashboard panes — đã có.
|
|
|
|
## 9. Effort Summary — Updated với Q1-Q6
|
|
|
|
| Wave | Items | Dev-days | Parallelizable |
|
|
|---|---|---|---|
|
|
| 1 | 8.0 (Foundation + ConfirmOverlay Q5) + 8.3.A (Router) + 8.2.A (Heartbeat) | 2.5 | Yes (3 streams) |
|
|
| 2 | 8.1.A → B → C → D (Q6) → E (Q1) → F (Q5) | 5 | No (sequential UX, share overlay state) |
|
|
| 3 | 8.2.B + 8.2.C + 8.2.D (Q4 R/K/D) + 8.3.B (Q3) + 8.3.C + 8.3.D (Q2) | 4 | Yes (5 streams) |
|
|
| 4 | 8.4 (Wire) + 8.5 (Tests) | 2.75 | No |
|
|
| **Total** | **17 sub-phases** | **14-18** | — |
|
|
|
|
**So với plan gốc:** +3.35 dev-day, +5 sub-phases, +8 file mới, +27 unit case, +4 integration case.
|
|
|
|
## 10. Acceptance Checklist (Wave 4 exit criteria) — Updated
|
|
|
|
- [x] Tất cả checkbox 8.0 → 8.5 ở mục 0 (Implementation Status) tick `[x]`.
|
|
- [x] `npm test` ≥ **351 unit** (current 299 + 52 mới), ≥ **35 integration** (current 29 + 6 mới), 0 fail. Verified: 351 unit + 44 integration pass.
|
|
- [x] `npm run typecheck` clean.
|
|
- [x] Manual smoke **8 scenarios** pass (mục 6). Verified via automated smoke suite `test/integration/phase8-smoke.test.ts`.
|
|
- [x] Performance budget thỏa: mailbox overlay <50ms, compose preview <30ms, sink write <5ms, diagnostic export <200ms. Verified microbench: mailbox 6.39ms, preview 1.61ms, health 0.29ms, sink 2.12ms, diagnostic 4.83ms.
|
|
- [x] No regression: 299 unit + 29 integration cũ vẫn pass.
|
|
- [x] Config breaking? **No.** Schema additive (`notifications` section optional).
|
|
- [x] Bump `package.json` version `0.1.33` → `0.1.34`.
|
|
- [x] Q1-Q6 implementations match decisions table mục 7.
|
|
- [x] Secret redaction (Q4): test fixture with recursive key/value redaction pass; audit log avoids known token fixture.
|
|
|
|
## 11. Out of Scope (defer Phase 9+)
|
|
|
|
> Phase 9 plan đã được tạo riêng tại [`research-phase9-observability-reliability-plan.md`](./research-phase9-observability-reliability-plan.md).
|
|
|
|
- **Telemetry/Metrics backbone** (Counter/Gauge/Histogram + correlation ID + OTLP/Prometheus export) → **Phase 9 (Theme B)** per Path X plan.
|
|
- **Run reliability** — auto-retry executor + crash recovery + deadletter + heartbeat watcher → **Phase 9 (Theme C)**.
|
|
- Cross-run mailbox routing (operator-broadcast) — **Phase 10+**.
|
|
- Mailbox threading / reply chains — **Phase 10+**.
|
|
- **Compose draft persistence (Q6 b/c options)** — defer Phase 9 nếu user feedback than.
|
|
- Multi-host run aggregation — **Phase 10+**.
|
|
- Slack/Discord webhook sink (router supports it via custom sink, but no built-in adapter) — **Phase 10+**.
|
|
- Markdown preview với images/links rendered (Q1 D15 skip) — **Phase 10+**.
|
|
|
|
### Path X roadmap summary
|
|
|
|
| Phase | Theme | Effort | Plan file |
|
|
|---|---|---|---|
|
|
| 6 | `.crew/` migration + autonomous policy | ~12d | `refactor-tasks-phase6.md` (DONE) |
|
|
| 7 | UI Optimization | ~18d | `research-ui-optimization-plan.md` (DONE) |
|
|
| **8** | **Operator Experience (Theme A)** | **14-18d** | **THIS FILE — ✅ DONE (verified 351 unit + 44 integration pass, version 0.1.34)** |
|
|
| **9** | **Observability + Reliability (B+C)** | **19.5-22.5d** | `research-phase9-observability-reliability-plan.md` (post-review updated 2026-04-29) |
|
|
| 10+ | TBD: Perf baseline, distributed | — | Future |
|
|
|
|
---
|
|
|
|
## 12. Implementation Kickoff Checklist (Pre-Wave 1)
|
|
|
|
Trước khi bắt đầu Wave 1, verify:
|
|
|
|
- [x] Phase 7 đã commit (snapshot cache + render scheduler + 4 panes). Included in `phase-8-operator-experience` release commit.
|
|
- [x] `npm test` baseline pass (299 unit + 29 integration). Verified current suite: 351 unit + 44 integration pass.
|
|
- [x] `npm run typecheck` clean.
|
|
- [x] Q1-Q6 đã chốt (đã làm — table mục 7).
|
|
- [x] Branch mới `phase-8-operator-experience` từ main.
|
|
- [x] Read once: `src/extension/team-tool/api.ts` (đã có ack-message/send-message/nudge-agent operations — KHÔNG cần modify).
|
|
- [x] Read once: `src/ui/run-dashboard.ts:handleInput` để hiểu pattern key dispatch hiện tại.
|
|
- [x] Read once: `src/ui/live-run-sidebar.ts` để có template cho overlay implementation.
|
|
|
|
**Sẵn sàng triển khai Phase 8 Path X.**
|