diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 0000000..bea2b4e --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,77 @@ +name: Release validation + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: release-validation-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + tests: + name: Tests and types (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '24' + cache: npm + - name: Install locked dependencies without lifecycle scripts + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Full automated suite + run: npm test + - name: TypeScript + run: npm run typecheck + - name: Lockfile and source whitespace + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git diff --exit-code -- package.json package-lock.json + git diff --check "$BASE_SHA" HEAD + + package: + name: Build and package (Ubuntu) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + BB_TELEMETRY: 'false' + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '24' + cache: npm + - name: Install locked project dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Prepare separate official BB 0.46.0 build tools + shell: bash + run: | + npm install --prefix "$RUNNER_TEMP/bb-tools" --ignore-scripts --no-audit --no-fund bb-app@0.46.0 + cd "$RUNNER_TEMP/bb-tools" + npm rebuild better-sqlite3 + - name: Official full plugin production build + shell: bash + env: + BB_DATA_DIR: ${{ runner.temp }}/agent-board-build-data + BB_SERVER_URL: http://127.0.0.1:50886 + run: node "$RUNNER_TEMP/bb-tools/node_modules/bb-app/dist/bb.js" plugin build + - name: Inspect actual release package and metadata + run: npm run verify:package + - name: Ensure build/install did not alter locked source + run: git diff --exit-code -- package.json package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bdc1fa..2784396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.7.0 — prepared 2026-10-12 (unpublished) + +- Show the latest observed public activity and distinguish recent from quiet + reporting, without changing task status or the indeterminate activity strips. +- Add structured completion outcome/result/failure sections, explicitly labeled + agent-reported verification and access to the original bounded output preview. +- Filter and group the bounded dashboard by project, repository, provider and + status. Project metadata lookup is optional, bounded and credential-safe. +- Separate transport connection, refresh/change timestamps and watcher health. + Contain watcher callback/publication errors and surface degraded monitoring. +- Reduce empty Needs attention to a compact line; actionable items remain visible. +- Add bundled What's New highlights with per-version local dismissal and a + persistent Release notes control. No telemetry or remote release fetching. +- Retain explicit result/check labels before truncating the original output preview. +- Qualify cached watcher health when disconnected, a refresh fails or becomes overdue. +- Keep terminal duration unknown when the source supplies no completion timing. +- Bound package contents to runtime source, assets and user documentation; + exclude tests, validation helpers, reports and temporary evidence from npm packs. +- Make public-SDK test paths portable to Windows and use a serial Vitest thread + pool for reliable startup on the development laptop. + +- Add Node 24 Ubuntu/Windows PR tests/typecheck and an Ubuntu official build/package gate. +- Patch Hono, Undici and source-map-js development dependencies; the updated graph audits clean. +- Prebundle the large icon data dependency in Vitest without relaxing hook limits or mocking the UI. + +No storage migration or settings reset is required. BB >=0.40 and SDK >=0.4.21 +remain declared; BB 0.46.0 is recommended. See [upgrade notes](docs/upgrade-0.7.0.md) +and [release convention](docs/release-convention.md). Version 0.7.0 is prepared on the PR branch and is not published. + ## 0.6.1 — first public beta - Fix managed Git installation when BB omits development dependencies: keep the @@ -7,7 +36,7 @@ - 0.6.0 remained an unpublished release candidate. Dates are omitted because these milestones were local development commits. -0.6.0 is the first planned public beta. +0.6.1 is the first public beta; 0.6.0 remained unpublished. ## 0.6.0 - Optional Ollama Fleet public observability integration. diff --git a/PLUGIN_OVERVIEW.md b/PLUGIN_OVERVIEW.md index eab8927..3a0c5e7 100644 --- a/PLUGIN_OVERVIEW.md +++ b/PLUGIN_OVERVIEW.md @@ -5,9 +5,19 @@ completion. Open a thread's Agent Board action for automatic single-agent focus or a multi-agent Kanban view. Inspect the bounded execution timeline to see public operational updates, actual models, and tools as sources report them. +See how recently an agent reported public activity. Quiet reporting keeps the +source's task status intact. Review completion outcomes, results, failures and +explicitly agent-reported checks, then open the original thread or timeline for +details. Filter and group visible work by project, repository, provider or status. +Needs Attention stays visible across filters. Connection, refresh and watcher +health help you distinguish an observation problem from quiet execution. + +What's New shows bundled version highlights. Dismiss an announcement once and +reopen its notes from the dashboard whenever needed. + ## Requirements -Requires BB. Ollama Fleet 0.6.0 or newer and Redteam's public observability RPC +Requires BB 0.40 or newer; BB 0.46.0 is recommended. Ollama Fleet 0.6.0 or newer and Redteam's public observability RPC are optional integrations. Missing integrations leave native BB observation available. Mobile layouts and reduced-motion preferences are supported. Hidden reasoning is excluded. System/push notifications are unavailable in diff --git a/README.md b/README.md index 353cf01..fbbe221 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ focus and the global dashboard offer other views of the same bounded state. - **Multi-Agent Kanban:** live state for concurrent agents and workers. - **Needs Attention:** structured waiting/failure states and persistent dismissal. - **Execution Timeline:** bounded operational events, loaded when opened. +- **Activity freshness:** recent or quiet public reporting without inferring a stall. +- **Completion summaries:** outcomes, results, failures and agent-reported checks. +- **Filters and grouping:** project, repository, provider and status over visible work. +- **Monitoring health:** connection, successful refresh and watcher status. +- **What's New:** version highlights with persistent dismissal and later viewing. - Native BB threads, workflows, delegations, models and tools. - Optional Ollama Fleet and Redteam observation. - Mobile layouts, keyboard controls, and reduced-motion support. @@ -64,6 +69,7 @@ bb plugin install . Neither Fleet nor Redteam is required. Declared compatibility is BB >=0.40 and SDK >=0.4.21; the development SDK is pinned to 0.4.34. See the honest [compatibility matrix](RELEASE_AUDIT.md), including historical runtime evidence. +For the prepared, unpublished 0.7.0 source, see [compatibility and upgrade notes](docs/upgrade-0.7.0.md). ## Usage @@ -144,6 +150,13 @@ This is a bounded latest view, not a persistent history database. ## Development +The prepared, unpublished 0.7.0 source is documented in [vnext launch/testing](docs/vnext-testing.md), +[design](docs/vnext-design.md) and [release convention](docs/release-convention.md). +Use a separate BB data directory and ports for development; keep your stable +installation untouched. `0.7.0` on this PR branch is not a published release. +See [dependency assessment](docs/dependency-security-0.7.0.md) and the PR +Release validation checks before approving a merge. + ```sh npm ci npm test diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d003375..9cae090 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,49 @@ +# Agent Board v0.7.0 — prepared release notes + +Version **0.7.0** is prepared on the development PR branch, unpublished and +awaiting final merge review. No public tag, release, marketplace update or +installed stable-plugin update has occurred. + +Agent Board gives you a clearer view of live agent work and completed outcomes. + +- **Activity freshness:** see the age of the latest public activity and whether + reporting is recent or quiet. Long tasks keep their authoritative status and + the existing animated activity bars. +- **Completion outcomes:** review results, failures and explicitly agent-reported + checks in separate sections. Open the original thread or bounded timeline for details. +- **Filters and grouping:** organize visible work by project, repository, provider + or status. Needs Attention and operational counts remain visible across filters. +- **Monitoring health:** distinguish transport connection, snapshot refresh and + source-watcher health. Cached watcher status is qualified when it cannot be confirmed. +- **What's New:** read bundled version highlights, persist dismissal for that + version, and reopen its notes from the dashboard. Empty Needs Attention is compact. + +Reliability fixes preserve explicit result/check metadata before output truncation, +qualify cached health during disconnects and failed/overdue refreshes, and avoid +inventing elapsed duration for terminal tasks without completion timing. + +Release hardening adds hosted Node 24 Linux/Windows tests and typecheck, an +Ubuntu official BB build/package gate, and fixed development Hono/Undici/source-map-js +patches. See [dependency assessment](docs/dependency-security-0.7.0.md). + +Existing settings and attention dismissals are preserved. No manual data migration +or configuration change is required. BB >=0.40 and public SDK >=0.4.21 remain +declared; BB **0.46.0 is recommended**. Optional Fleet >=0.6.0 and a Redteam +runtime exposing the public observability contract remain optional. + +BB installs tracking `^0.6.1` do not include 0.7.0. After publication, users must +select the new marketplace release or change their Git selector to `^0.7.0`. +Pinned tags/commits remain pinned. See [compatibility and upgrade notes](docs/upgrade-0.7.0.md). + +Filters cover the bounded visible snapshot. Quiet reporting does not prove a +stall, and reported checks are not independent test evidence. Release notes in +the UI are bundled highlights; GitHub hosts the full release notes. There are +no new push notifications, automatic-install promises or Phase 2 features. + +Candidate checks, coverage provenance and remaining limits are recorded in +[the validation summary](docs/validation-0.7.0.md). Detailed machine-specific +reports and live screenshots remain local development evidence. + # Agent Board v0.6.1 First public beta: live execution observability for BB. diff --git a/components/board/activity-freshness.tsx b/components/board/activity-freshness.tsx new file mode 100644 index 0000000..abf45e2 --- /dev/null +++ b/components/board/activity-freshness.tsx @@ -0,0 +1,11 @@ +import type { BoardCard } from "@/contract/rpc"; +import { activityFreshness } from "@/lib/dashboard-view"; +import { formatElapsed } from "@/lib/board-model"; + +export function ActivityFreshness({ card, now }: { card: BoardCard; now: number }) { + const freshness = activityFreshness(card, now); + return

+ {freshness.ageMs === null ? "Activity time unavailable" : `Last observed activity ${formatElapsed(freshness.ageMs)} ago`} + {freshness.state === "quiet" && (card.status === "running" || card.status === "starting") ? · Quiet reporting; task may still be running. : freshness.state === "recent" ? · Recent reporting : null} +

; +} diff --git a/components/board/board-card-view.tsx b/components/board/board-card-view.tsx index 9abed82..610e4e0 100644 --- a/components/board/board-card-view.tsx +++ b/components/board/board-card-view.tsx @@ -8,6 +8,8 @@ import { Icon } from "@/components/ui/icon"; import { cn } from "@/lib/utils"; import { StatusChip } from "@/components/board/status-chip"; import { LiveIndicator, LiveStrip } from "@/components/board/live-execution-cue"; +import { ActivityFreshness } from "./activity-freshness"; +import { CompletionSummary } from "./completion-summary"; import { ExecutionTimelineDialog, timelineTargetForCard } from "@/components/timeline/execution-timeline-dialog"; /** Kind → icon hint (core Icon names). */ @@ -141,14 +143,14 @@ export function BoardCardView({ ) : null} {/* Failure text — failures stay visible, not hidden. */} - {card.errorPreview !== null ? ( + {card.errorPreview !== null && card.column !== "output" ? (

{card.errorPreview}

) : null} {/* Output preview for finished work. */} - {card.outputPreview !== null ? ( + {card.outputPreview !== null && card.column !== "output" ? (

{card.outputPreview}

@@ -167,6 +169,8 @@ export function BoardCardView({ ) : null} {/* Footer: elapsed, tokens/tool calls, actions. */} + {executing ? : null} + {card.column === "output" && card.kind !== "plan-step" ? : null}
{elapsedMs !== null ? {formatElapsed(elapsedMs)} : null} {card.tokens !== null ? {card.tokens.toLocaleString()} tok : null} diff --git a/components/board/completion-summary.tsx b/components/board/completion-summary.tsx new file mode 100644 index 0000000..986e3ab --- /dev/null +++ b/components/board/completion-summary.tsx @@ -0,0 +1,14 @@ +import type { BoardCard } from "@/contract/rpc"; +import { completionSummary } from "@/lib/dashboard-view"; + +export function CompletionSummary({ card }: { card: BoardCard }) { + const summary = completionSummary(card); + return
+

Outcome: {summary.outcome}

+ {summary.failure ?

Failure: {summary.failure}

: null} +

Result: {summary.result}

+

Reported verification: {summary.reportedChecks.length ? summary.reportedChecks.join(" · ") : "Not supplied"}

+ {summary.reportedChecks.length ?

Reported by the agent; not independently verified.

: null} + {summary.originalPreview ?
Original output preview

Source preview: {summary.originalPreview}

Preview is bounded. Use Open for the detailed source output.

: null} +
; +} diff --git a/components/board/single-agent-focus.tsx b/components/board/single-agent-focus.tsx index 42d4a50..d70fedd 100644 --- a/components/board/single-agent-focus.tsx +++ b/components/board/single-agent-focus.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; import { StatusChip } from "@/components/board/status-chip"; import { PipelineTrack } from "@/components/board/pipeline-track"; import { BoardCardView } from "@/components/board/board-card-view"; +import { ActivityFreshness } from "./activity-freshness"; import { usePrefersReducedMotion } from "@/components/ui/hooks/use-media-query"; import { ExecutionTimelineDialog, timelineTargetForCard } from "@/components/timeline/execution-timeline-dialog"; @@ -241,6 +242,7 @@ export function SingleAgentFocus({ ) : null} {/* Truthful stats; unavailable fields are simply omitted. */} + {stats.length > 0 ? (
{stats.map((stat) => ( diff --git a/components/global/global-dashboard.tsx b/components/global/global-dashboard.tsx index 78f1f92..830273c 100644 --- a/components/global/global-dashboard.tsx +++ b/components/global/global-dashboard.tsx @@ -1,3 +1,4 @@ +import { useState, type ReactNode } from "react"; import { useBbNavigate } from "@get-bb/plugin-sdk/app"; import type { AttentionItem, GlobalExecution } from "@/contract/rpc"; import { useGlobalDashboard } from "@/hooks/useGlobalDashboard"; @@ -9,6 +10,15 @@ import { StatusChip } from "@/components/board/status-chip"; import { LiveIndicator, LiveStrip } from "@/components/board/live-execution-cue"; import { cn } from "@/lib/utils"; import { ExecutionTimelineDialog, timelineTargetForCard } from "@/components/timeline/execution-timeline-dialog"; +import { ActivityFreshness } from "@/components/board/activity-freshness"; +import { CompletionSummary } from "@/components/board/completion-summary"; +import { ReleaseAnnouncement } from "./release-announcement"; +import { EMPTY_FILTERS, filterDashboard, filterOptions, groupExecutions, type DashboardFilters, type FilterField } from "@/lib/dashboard-view"; +const FILTER_PLURALS: Record = { project: "projects", repository: "repositories", provider: "providers", status: "statuses" }; + +function ExecutionGroups({ executions, grouping, render }: { executions: GlobalExecution[]; grouping: FilterField | "none"; render: (execution: GlobalExecution) => ReactNode }) { + return groupExecutions(executions, grouping).map(group => group.label === null ? group.executions.map(render) :

{group.label} ({group.executions.length})

{group.executions.map(render)}
); +} function relativeAge(at: number, now: number): string { return `${formatElapsed(Math.max(0, now - at))} ago`; @@ -75,6 +85,8 @@ function GlobalExecutionCard({ execution, now, reducedMotion }: { execution: Glo

{card.waitingOn}

) : null} {executing ?
: null} + + {execution.projectName || execution.projectId ?

{execution.projectName ?? execution.projectId}{execution.repository ? ` · ${execution.repository}` : ""}

: null}
{elapsed !== null ? {formatElapsed(elapsed)} : null} {execution.activeChildCount > 0 ? {execution.activeChildCount} active child{execution.activeChildCount === 1 ? "" : "ren"} : null} @@ -151,7 +163,7 @@ function RecentRow({ execution, now }: { execution: GlobalExecution; now: number {card.model !== null ? <> · {card.model} : null} <> · {relativeAge(finishedAt, now)}

- {card.errorPreview !== null || card.outputPreview !== null ?

{card.errorPreview ?? card.outputPreview}

: null} +
{card.threadId !== null ? : null} @@ -162,9 +174,15 @@ function RecentRow({ execution, now }: { execution: GlobalExecution; now: number } export function GlobalDashboard() { - const { snapshot, loading, refreshing, error, refetch, dismissAttention, clearDismissedAttentions, connectionState } = useGlobalDashboard(); + const { snapshot, loading, refreshing, error, refetch, dismissAttention, clearDismissedAttentions, connectionState, lastSignalAt, lastRefreshAt } = useGlobalDashboard(); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [grouping, setGrouping] = useState("none"); + const executions = snapshot ? [...snapshot.active, ...snapshot.recent] : []; + const visible = snapshot ? filterDashboard(snapshot, filters) : { active: [], recent: [] }; + const filtered = Object.values(filters).some(Boolean); const reducedMotion = usePrefersReducedMotion(); const now = Date.now(); + const watcherUnconfirmed = connectionState !== "connected" || !!error || lastRefreshAt === null || now - lastRefreshAt > 60_000; return (
@@ -179,9 +197,24 @@ export function GlobalDashboard() {
- {connectionState === "connected" ? "Live" : "Reconnecting…"} + {connectionState === "connected" ? "Connected" : `Connection: ${connectionState}`}
+ +
+

{lastRefreshAt === null ? "Waiting for first snapshot" : `Last refresh ${relativeAge(lastRefreshAt, now)}`}{lastRefreshAt !== null && now - lastRefreshAt > 60_000 ? " · Refresh is overdue" : ""}

+

{lastSignalAt === null ? "No change signal received in this view yet" : `Last change signal ${relativeAge(lastSignalAt, now)}`}

+ {snapshot?.monitoring ?

{watcherUnconfirmed ? `Watcher health unconfirmed · Last known state: ${snapshot.monitoring.status}` : `Watcher: ${snapshot.monitoring.status}`}{snapshot.monitoring.lastEventAt === null ? " · No relevant source event observed yet" : ` · Source event ${relativeAge(snapshot.monitoring.lastEventAt, now)}`}

: null} +
+ {snapshot?.monitoring && ["degraded", "unavailable", "disposed"].includes(snapshot.monitoring.status) ?

Event watcher has a problem. Snapshot refreshes may still work. Refresh or reload Agent Board.

: null} + {snapshot ?
+
+ {(["project", "repository", "provider", "status"] as FilterField[]).map(field => )} + +
+

Filters apply to this bounded snapshot. Needs attention and summary counts cover all observed work.

+ {filtered ? : null} +
: null} {snapshot !== null ? (
@@ -201,20 +234,21 @@ export function GlobalDashboard() { ) : null}
- {snapshot.attention.length > 0 ?
{snapshot.attention.map((item) => void dismissAttention(dismissed)} />)}
:

{snapshot.dismissedCount > 0 ? "No open items — dismissed conditions stay hidden until they change." : "Nothing currently requires your attention."}

} + {snapshot.attention.length > 0 ?
{snapshot.attention.map((item) => void dismissAttention(dismissed)} />)}
:

{snapshot.dismissedCount > 0 ? "No open items — dismissed conditions stay hidden until they change." : "Nothing currently requires your attention."}

}
-

Active work

{snapshot.active.length}
- {snapshot.active.length > 0 ?
{snapshot.active.map((execution) => )}
:

No active executions.

} +

Active work

{visible.active.length}
+ {visible.active.length > 0 ?
} />
:

{filtered ? "No active executions match these filters." : "No active executions."}

}
-

Recently completed

{snapshot.recent.length}
- {snapshot.recent.length > 0 ?
{snapshot.recent.map((execution) => )}
:

No recent completions in the bounded window.

} +

Recently completed

{visible.recent.length}
+ {visible.recent.length > 0 ?
} />
:

{filtered ? "No recent completions match these filters." : "No recent completions in the bounded window."}

}
- {snapshot.unavailableSources.length > 0 ?

Temporarily unavailable: {snapshot.unavailableSources.map((source) => source === "bb" ? "BB" : source === "ollama-fleet" ? "Ollama Fleet" : "Redteam").join(", ")}. Available sources remain live.

: null} + {snapshot.unavailableSources.includes("bb") ?

Native BB observation is unavailable. Refresh to retry; optional sources may still report.

: null} + {snapshot.unavailableSources.some(source => source !== "bb") ?

Optional sources unavailable: {snapshot.unavailableSources.filter(source => source !== "bb").map(source => source === "ollama-fleet" ? "Ollama Fleet" : "Redteam").join(", ")}. Missing optional integrations do not prevent BB observation.

: null} {snapshot.partial ?

The global view is bounded; older or excess executions are not shown.

: null}
: null} diff --git a/components/global/release-announcement.tsx b/components/global/release-announcement.tsx new file mode 100644 index 0000000..f56117c --- /dev/null +++ b/components/global/release-announcement.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { useRpc } from "@get-bb/plugin-sdk/app"; +import type { rpcContract } from "@/contract/rpc"; +import { Button } from "@/components/ui/button"; + +type Release = { version: string; title: string; highlights: string[]; dismissed: boolean }; +export function ReleaseAnnouncement() { + const rpc = useRpc(); + const [release, setRelease] = useState(null); + const [viewing, setViewing] = useState(false); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + useEffect(() => { + let cancelled = false; + rpc.call("release_announcement", {}).then(result => { if (!cancelled) { setRelease(result); setError(null); } }, () => { if (!cancelled) setError("Release notes could not be loaded."); }); + return () => { cancelled = true; }; + }, [rpc]); + async function dismiss() { + if (!release || saving) return; + setSaving(true); + try { + await rpc.call("dismiss_release_announcement", { version: release.version }); + setRelease({ ...release, dismissed: true }); setViewing(false); setError(null); + } catch { setError("Dismissal could not be saved. Please try again."); } + finally { setSaving(false); } + } + return
+ + {error ?

{error}

: null} + {release && (!release.dismissed || viewing) ?
+

What’s New · {release.version}

+

{release.title}

+
    {release.highlights.map(highlight =>
  • {highlight}
  • )}
+
: null} +
; +} diff --git a/contract/rpc.ts b/contract/rpc.ts index 13a3119..030830f 100644 --- a/contract/rpc.ts +++ b/contract/rpc.ts @@ -123,8 +123,13 @@ export const boardCardSchema = z.object({ /** Task preview where the source exposes one (workflow promptPreview). */ promptPreview: z.string().nullable(), activity: boardActivitySchema.nullable(), + /** Most recent public activity; never the snapshot fetch time. */ + lastActivityAt: z.number().nullable().optional(), /** Compact final-output preview (truncated server-side). */ outputPreview: z.string().nullable(), + /** Explicit agent-reported labels, retained before output preview truncation. */ + completionResult: z.string().max(240).nullable().optional(), + reportedVerification: z.array(z.string().max(240)).max(4).optional(), /** * Latest user-visible assistant-authored text (bounded excerpt). Strictly * conversational: never reasoning, tool/command output, or user/system @@ -219,6 +224,8 @@ export const globalExecutionSchema = z.object({ card: boardCardSchema, projectId: z.string().nullable(), delegatedWorkers: z.array(boardCardSchema).max(4).optional(), + projectName: z.string().nullable().optional(), + repository: z.string().nullable().optional(), delegatedWorkerCount: z.number().int().nonnegative().optional(), activeDelegatedWorkerCount: z.number().int().nonnegative().optional(), childCount: z.number().int().nonnegative(), @@ -353,6 +360,13 @@ export const globalDashboardCountsSchema = z.object({ export type GlobalDashboardCounts = z.infer; const globalDashboardSnapshotSchema = z.object({ + monitoring: z.object({ + status: z.enum(["watching", "degraded", "unavailable", "disposed"]), + startedAt: z.number(), + lastEventAt: z.number().nullable(), + lastPublishedAt: z.number().nullable(), + lastErrorAt: z.number().nullable(), + }).optional(), active: z.array(globalExecutionSchema), attention: z.array(attentionItemSchema), recent: z.array(globalExecutionSchema), @@ -382,6 +396,14 @@ export const boardChangedPayloadSchema = z.object({ }); export const rpcContract = { + release_announcement: { + input: z.object({}), + output: z.object({ version: z.string(), title: z.string(), highlights: z.array(z.string()), dismissed: z.boolean() }), + }, + dismiss_release_announcement: { + input: z.object({ version: z.string().min(1).max(80) }), + output: z.object({ dismissed: z.boolean() }), + }, /** * Normalized board for one root thread and its publicly visible * descendants/workflows. `fresh: true` bypasses the short-lived diff --git a/docs/architecture.md b/docs/architecture.md index 122fb38..bca105b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ Agent Board is a generic BB execution-observability plugin with two complementar The mode is derived from the normalized snapshot by a pure view model (`lib/focus-model.ts`); no source-specific logic lives in the UI. The board is derived from authoritative sources on demand. It stores no duplicate board state and never renders reasoning/thinking events, fabricated progress percentages, or raw tool output. -The global page uses the stable public `app.slots.navPanel` surface and `useBbNavigate().toThread(...)`. `lib/global-model.ts` collapses normalized source cards into independent logical executions, derives attention only from explicit structured metadata, and removes resolved conditions automatically. It does not inspect assistant prose. Stale/no-activity warnings are intentionally omitted because current timestamps cannot prove a stall reliably. +The global page uses the stable public `app.slots.navPanel` surface and `useBbNavigate().toThread(...)`. `lib/global-model.ts` collapses normalized source cards into independent logical executions, derives attention only from explicit structured metadata, and removes resolved conditions automatically. It does not inspect assistant prose to infer attention. Phase 1 adds public-activity age and recent/quiet reporting labels; these never prove a stall or change execution status. Completion verification is explicitly agent-reported. See [the vnext design](vnext-design.md). ## Sources diff --git a/docs/dependency-security-0.7.0.md b/docs/dependency-security-0.7.0.md new file mode 100644 index 0000000..b991e48 --- /dev/null +++ b/docs/dependency-security-0.7.0.md @@ -0,0 +1,73 @@ +# Development dependency assessment for 0.7.0 + +Assessed 12 October 2026. GitHub's eight default-branch alerts were mapped to the +actual lockfile and code paths. No affected Hono/Undici/source-map-js module was +present in the plugin's generated backend source map. These packages are +development dependencies, not declared production dependencies. That distinction +does not exempt build/test tooling from malicious inputs or supply-chain risk. + +## Dependency paths and narrow updates + +- Direct development Hono **4.13.5 → 4.13.7**. The SDK 0.4.34 testing host uses + that root optional peer; its compatible `^4.11.9` peer requirement is satisfied. + The root minimum becomes `^4.13.7` without upgrading the SDK. +- `jsdom@30.0.1 → undici` **8.10.1 → 8.10.2**, pinned by a transitive override. + jsdom's existing `^8.9.0` range accepts this patch. +- `jsdom → css-tree → source-map-js` and + `vitest → vite → postcss → source-map-js` **1.2.1 → 1.2.2**, pinned by a + transitive override. Existing transitive ranges accept this patch. + +Only those three resolved package versions changed. Production dependencies, +BB runtime/build version 0.46.0, SDK pin 0.4.34, Node major 24 and declared BB/SDK +compatibility floors were retained. No major upgrade or forced audit fix was used. +Locked root engine metadata was reconciled with the existing package declarations: +BB >=0.40 and SDK >=0.4.21. Unrelated Linux libc metadata was retained. + +## Original eight GitHub alerts + +Every row retains its original alert; no alerts were merged or dismissed. Fixed +means the PR lockfile selects a patched version, not that an exploit was reproduced +or that GitHub's default-branch alert is closed before merge. + +| Alert / advisory | Severity | Affected range / first fixed | Mechanism and current reachability | Action / status | +| --- | --- | --- | --- | --- | +| #12 [GHSA-hxh3-vqpv-xpqv](https://github.com/advisories/GHSA-hxh3-vqpv-xpqv), CVE-2026-93981 | Medium | Hono <4.13.7 / 4.13.7 | Unescaped strings in Hono JSX server boundaries/root can cause XSS. Agent Board uses React; its fake SDK HTTP routes return JSON and do not use Hono JSX/SSR. No current shipped/harness exploit path established. | Defensive patch to 4.13.7; fixed in PR lock. | +| #11 [GHSA-pmjh-fq2x-6v4x](https://github.com/advisories/GHSA-pmjh-fq2x-6v4x), CVE-2026-18149 | Medium | Undici >=8.0.0 <8.10.2 / 8.10.2 | A malicious retried response can leave a body pending. Current harness does not configure RetryHandler/retry interception. | Defensive patch to 8.10.2; fixed in PR lock. | +| #10 [GHSA-2gqq-gqf2-x968](https://github.com/advisories/GHSA-2gqq-gqf2-x968), CVE-2026-84947 | Low | Undici >=8.0.0 <8.10.2 / 8.10.2 | Oversized chunked responses can be truncated by the dump interceptor. No dump interceptor is configured. | Same patch; fixed in PR lock. | +| #9 [GHSA-2jfj-6hjv-fm6j](https://github.com/advisories/GHSA-2jfj-6hjv-fm6j), CVE-2026-84933 | Medium | Undici >=8.0.0 <8.10.2 / 8.10.2 | Shared cache can replay Set-Cookie across users. No shared cache interceptor is configured. | Same patch; fixed in PR lock. | +| #6 [GHSA-r53p-7pc4-xj5r](https://github.com/advisories/GHSA-r53p-7pc4-xj5r), CVE-2026-18540 | Low | Undici >=8.0.0 <8.10.2 / 8.10.2 | Retried bytes forwarded with old framing can split downstream responses. No retry interceptor or downstream HTTP proxy is configured. | Same patch; fixed in PR lock. | +| #5 [GHSA-w293-vg96-wgc3](https://github.com/advisories/GHSA-w293-vg96-wgc3), CVE-2026-84961 | High | Undici >=8.0.0 <8.10.2 / 8.10.2 | BalancedPool drops function-valued custom TLS checks. The current jsdom dispatcher uses the default Agent, not BalancedPool/custom TLS callbacks. | Same patch; fixed in PR lock. | +| #4 [GHSA-8436-99hf-9mmv](https://github.com/advisories/GHSA-8436-99hf-9mmv), CVE-2026-85008 | Low | Undici >=8.0.0 <8.10.2 / 8.10.2 | A cache interceptor can suppress/replay state-changing HTTP requests. No cache interceptor is configured. | Same patch; fixed in PR lock. | +| #2 [GHSA-vp8m-p9jh-q5pm](https://github.com/advisories/GHSA-vp8m-p9jh-q5pm), CVE-2026-85152 | High | Undici >=8.10.0 <8.10.2 / 8.10.2 | Shared originless cache/deduplication keys permit cross-origin poisoning. No such interceptor is configured; default Agent carries request origin. | Same patch; fixed in PR lock. | + +Static triage classified these eight supplied application claims as not actionable +in the current inspected plugin/harness paths, rather than confirming them from +package presence alone. All received cheap compatible preventive patches anyway. +Future use of these features or untrusted network input would require reassessment. + +## Additional npm audit evidence + +`npm audit` also identified +[GHSA-68fv-2mgg-jv7q / CVE-2026-93749](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) +in source-map-js >=1.0.0 <1.2.2 (high). An indexed source map with extreme section +offsets can synchronously block the event loop. Its build/test dependency path is +real; a complete attacker-controlled-map path in this project was not established. +This was a potential build-time exposure requiring review, not a confirmed shipped +plugin DoS. The 1.2.2 override removes the vulnerable version without relying on +risk acceptance. + +The same Undici patch also covers npm's additional WebSocket decompression, +subprotocol, unclean-close and HTTP decompression advisories +([GHSA-3wwx-pv8p-q78v](https://github.com/advisories/GHSA-3wwx-pv8p-q78v), +[GHSA-rfgv-xxqx-mfg5](https://github.com/advisories/GHSA-rfgv-xxqx-mfg5), +[GHSA-rx4f-c7p8-82vq](https://github.com/advisories/GHSA-rx4f-c7p8-82vq), +[GHSA-3xpg-4rpp-hhhm](https://github.com/advisories/GHSA-3xpg-4rpp-hhhm)). +These concern malicious network responses in build/test libraries; no production +Undici dependency or claimed runtime exploit was introduced. After locked install, +`npm audit --json` returned exit 0 with zero reported vulnerabilities. + +No exploit PoC, exhaustive security scan or independent Node/BB host dependency +audit is claimed. Hosted CI uses disposable GitHub runners, read-only permissions, +no repository secrets, no persisted checkout credentials, no `pull_request_target` +execution and no uploaded runtime artifacts. GitHub alerts on unchanged main can +remain open until merge/reindexing; this task does not change their status. diff --git a/docs/marketplace-0.7.0-proposal.md b/docs/marketplace-0.7.0-proposal.md new file mode 100644 index 0000000..26011d1 --- /dev/null +++ b/docs/marketplace-0.7.0-proposal.md @@ -0,0 +1,30 @@ +# Marketplace update proposal — not submitted + +Current reviewed entry: `get-bb/marketplace/entries/agent-board.json`, source +range `^0.6.1`. Preserve its id, source URL, category, author, icon and existing +reviewed synthetic screenshot. After 0.7.0 publication, propose: + +```json +{ + "description": "Follow live BB agent activity, freshness, completion outcomes and monitoring health. Filter and group visible work by project, repository, provider or status. Optional Ollama Fleet and Redteam add worker visibility. Requires BB 0.40+; BB 0.46.0 recommended.", + "source": { + "git": { + "url": "https://github.com/aaronphifer/bb-plugin-agent-board.git", + "range": "^0.7.0" + } + } +} +``` + +This is a fragment describing edits to the existing entry, not a standalone +marketplace manifest. Copy the updated `PLUGIN_OVERVIEW.md` into +`overview/agent-board.md`. Compatibility remains in package engines; do not add +unsupported compatibility/version fields to the marketplace entry. Do not use +live validation screenshots containing private task details as listing media. + +Existing installs retain their stored source range. Tell `^0.6.1` users to +select/reinstall the new marketplace release or use the `^0.7.0` Git command in +the upgrade notes. Do not ask them to uninstall, which would delete preferences. +Publishing only a `v0.7.0` tag will not move their existing 0.6.x selector. + +No marketplace repository edits, PRs, submissions or announcements were made. diff --git a/docs/release-convention.md b/docs/release-convention.md new file mode 100644 index 0000000..1f409a1 --- /dev/null +++ b/docs/release-convention.md @@ -0,0 +1,26 @@ +# Release notes convention + +This work is unreleased. `0.7.0` is prepared on the development PR branch after +the RC hardening/CI gates passed. It is not a public tag, release, marketplace or +production update. The changelog preparation date is not a publication date. + +For each approved release: + +1. Finalize the `Unreleased` section of CHANGELOG.md, with user-facing changes, + relevant fixes, compatibility and known limitations. Move it to a versioned + section with the actual release date; keep earlier sections intact. +2. Set package.json and the lockfile root to the same version. `lib/releases.ts` + imports that package version and provides the bundled announcement title and + highlights. Do not include unverified test claims in those highlights. +3. Put the complete human-facing notes in RELEASE_NOTES.md, led by the new + version. Keep previous notes below it. Link compatibility/testing evidence. +4. Use GitHub release title `Agent Board vX.Y.Z`, tag `vX.Y.Z`, and copy the + finalized release notes into its body. Keep pre-release builds marked as + pre-releases. A repository tag/release or marketplace change requires the + maintainer's explicit approval; this convention grants none. + +The UI reads only bundled notes, never GitHub or the marketplace. Dismissal is +stored in plugin KV for the exact version (latest 20 dismissed versions), so +reloads retain dismissal, upgrades show new notes, and a recent downgrade +retains its old dismissal. Release notes can be opened later from the dashboard. +Storage failures are shown, not silently treated as saved. diff --git a/docs/upgrade-0.7.0.md b/docs/upgrade-0.7.0.md new file mode 100644 index 0000000..7f3b62b --- /dev/null +++ b/docs/upgrade-0.7.0.md @@ -0,0 +1,72 @@ +# Agent Board 0.7.0 compatibility and upgrade notes + +The current PR source is **0.7.0**, prepared but unpublished. Do not install it +over a stable installation as part of release review. The public 0.6.1 tag remains +the existing released installation until a separately approved publication. + +## Compatibility + +The package declares BB `>=0.40` and public Plugin SDK `>=0.4.21`. +BB **0.46.0** is the recommended runtime, also the latest published `bb-app` +version checked during this review on 12 October 2026. The development/test SDK +remains pinned to **0.4.34**. The BB 0.40.0 builder stamps SDK 0.4.21; the +BB 0.46.0 builder stamps SDK 0.6.37. These are different roles: the development +pin supplies types/test harnesses, while BB builds and supplies runtime shims. +The SDK and production dependencies are unchanged; development Hono, Undici and +source-map-js received compatible patch fixes. No BB/SDK compatibility range was +narrowed. See [dependency assessment](dependency-security-0.7.0.md). + +Phase 1 adds one BB SDK read, `projects.get`, using `name` and `gitRemoteUrl`. +Both fields and that method exist in SDK 0.4.21. Project lookup is optional, +bounded, cached and failure-tolerant. New announcement RPCs use existing plugin +RPC and KV storage APIs. Existing realtime, navigation and lifecycle APIs are +reused. No new permission/capability declaration, migration, provider access +or external service is required. + +A disposable BB 0.40.0 runtime built, installed and served the preview. +Candidate checks are detailed in the [validation summary](validation-0.7.0.md). A source-only +typecheck against SDK 0.4.21 reports eight references to the newer optional queue +`waitingOn` field; the unchanged 0.6.1 source reports the same eight errors. +Those accesses already tolerate an absent field at runtime. New Phase 1 API +uses pass that static comparison. This is not a claim that the full development +suite runs on the oldest SDK harness. + +BB 0.40.0 could not refresh today's community catalog because it accepts at +most 256 entries. Direct local plugin installation and RPC smoke work; use +BB 0.46.0 for current marketplace discovery. Fresh Linux graphical-browser and +physical-device checks remain limited. The RC subsequently passed Windows +automated/build and browser/live-provider validation; prior Linux live-provider +coverage applies to the same reviewed runtime source. See the validation summary +for the distinction between fresh and historical checks. + +Fleet >=0.6.0 is optional. Redteam is optional and must actually expose +`observability_snapshot` schema version 1; a version number alone is not enough. +The installed local Redteam 0.7.2 does not expose it and correctly remains unavailable. + +## Existing users + +Agent Board keeps the same plugin identity (`agent-board`). Existing settings +and attention-dismissal storage are retained; no manual migration or reset is +needed. The new release-dismissal preference is separate and version-specific. +The current announcement appears when upgrading from 0.6.1 or skipping earlier +releases. Missing/malformed dismissal storage shows the announcement again; +storage read/write failures are surfaced. Only the latest 20 dismissed versions +are retained. Bundled notes can be reopened after dismissal. + +After final approval and publication only: + +```sh +bb plugin install git:https://github.com/aaronphifer/bb-plugin-agent-board.git@^0.7.0 +``` + +Reinstalling the same identity through BB replaces its source while retaining +plugin preferences; do not remove/uninstall first, because removal deletes +plugin storage. Exact tag/commit installations stay pinned. `^0.6.1` permits +0.6.x but excludes 0.7.0. Changing the marketplace entry to `^0.7.0` affects new +selections, not the selector already stored by an existing installation. + +BB checks managed Git/npm installations for compatible updates and presents +them for an explicit update action. Path/builtin sources are pinned. Stable +semver ranges exclude prereleases unless the range explicitly names one. +Agent Board's What's New appears after installing a new version; it does not +discover remote releases, send push notifications or install updates itself. diff --git a/docs/validation-0.7.0.md b/docs/validation-0.7.0.md new file mode 100644 index 0000000..e0124f5 --- /dev/null +++ b/docs/validation-0.7.0.md @@ -0,0 +1,65 @@ +# Agent Board 0.7.0 validation summary + +Version 0.7.0 is prepared on the PR branch and is unpublished. These checks support development review, not +stable-release, marketplace or automatic-install approval. Detailed host-specific +reports, logs, synchronization manifests and live screenshots are retained +locally and excluded from the public commit. + +## Stable preparation and CI + +Release hardening patched the development Hono/Undici/source-map-js graph, which +returned zero npm audit findings. The development SDK/BB runtime/production +dependencies and animated activity source remain unchanged. A measured 100-second +Windows cold icon import was addressed by prebundling its real data-only module +without changing the 20-second hook limit or adding retries. + +The [hardening CI run](https://github.com/aaronphifer/bb-plugin-agent-board/actions/runs/38289933214) +passed all three hosted jobs before stable promotion: Ubuntu and Windows full tests/ +typecheck, and Ubuntu BB 0.46.0 full build plus actual package inspection. Stable +version tests/build/package checks run again before push and through the same +workflow on the new head. Inspect PR checks for their current result; historical +RC checks alone do not validate newly patched dependencies. + +See [dependency assessment](dependency-security-0.7.0.md) and [testing](vnext-testing.md). + +## Historical RC source and checks (12 October 2026) + +At the RC review, before public-documentation cleanup, 134 source inventory hashes matched between +Windows and Pop!_OS. Public cleanup only changes documentation and one launcher's +missing-tools help message; plugin implementation, tests, animation, dependencies, +version and compatibility declarations remain the reviewed candidate. + +| Coverage | Result and provenance | +| --- | --- | +| Windows full suite | 468 tests / 24 files passed. The first cold-import timeout was followed by an unchanged passing full rerun. | +| Windows adapter regression | 109 Fleet/Redteam tests / 6 files passed; a subset of the full suite. | +| Windows focused checks | 37 announcement, dashboard, JSON and public-SDK tests / 4 files passed; a subset of the full suite. | +| Windows typecheck / official build | Passed, BB 0.46.0 builder. Fresh frontend/backend metadata both identify the candidate. | +| Pop!_OS RC review | Independently passed 468 tests / 24 files, 109 adapter tests, typecheck and official build. | +| Minimum/current Linux runtime | BB 0.40.0 and 0.46.0 built, directly installed and served the RC in disposable hosts; RPC/app-asset/announcement smoke passed. | +| Production-only Linux install | Clean runtime dependency/source build and installation passed without the development SDK. | +| Actual Windows providers | Codex created and ran three Python unittests; Grok completed harmless delay/review tasks and explicitly reported tests not run. A controlled invalid-model failure appeared correctly. | +| Actual Linux providers | Earlier Linux validation ran Codex/Grok tasks and host recovery on the preview. Linux RC build/install/RPC smoke was checked separately. New inference tasks were not rerun in the independent Linux RC review. | +| Windows UI/storage | Monitoring health, saved attention preservation, version dismissal/reload/reopening, filtering/grouping, completion provenance, active animation, Open/Timeline and desktop/narrow rendering passed. | +| Package inspection | Fresh 68-file Windows package: correct plugin/version metadata, required runtime assets, unchanged dependencies and no test/runtime/credential data. | + +Windows native BB worked in the inspected environment. This is empirical evidence, +not a claim of general native Windows host support. Codex's Windows sandbox +startup failures required scoped one-time approvals for the disposable fixture; +no authentication, persistent permissions or networking settings were changed. + +The SDK 0.4.34 development pin differs from the builder SDK. A source-only +typecheck against the declared 0.4.21 floor finds eight existing optional queue +`waitingOn` typing errors, also present in unchanged 0.6.1. New Phase 1 SDK use +is present at that floor and optional at runtime. This does not claim the full +development harness runs against the oldest SDK package. + +Nonempty live Fleet/Redteam coverage remains limited. Optional Redteam needs the +public schema-version-1 observability RPC; a version number alone is insufficient. +BB 0.40.0's community-catalog entry cap limits current marketplace discovery, +while direct installation works. Physical-device and fresh Linux graphical +browser coverage remain separate. Existing bounded-history and agent-reported +verification limits still apply. + +See [testing instructions](vnext-testing.md), [compatibility/upgrade notes](upgrade-0.7.0.md), +[release notes](../RELEASE_NOTES.md) and [release procedure](release-convention.md). diff --git a/docs/vnext-design.md b/docs/vnext-design.md new file mode 100644 index 0000000..981b987 --- /dev/null +++ b/docs/vnext-design.md @@ -0,0 +1,40 @@ +# Phase 1 design (unreleased) + +Base: main, 92efdcdf89b838713dac7ebc9fc2eaef9e4b8860, plugin 0.6.1. +Development branch: feature/agent-board-vnext. This is an unpublished +release preparation. Stable installation and publication require separate approval. + +Reuse the public SDK collectors, normalized cards, bounded caches, lifecycle +watcher, optional Fleet/Redteam adapters and React hooks. Codex/Grok observation +is mediated by BB threads/providers; there are no provider session-file watchers. +Preserve LiveStrip, its CSS, reduced motion, source privacy and collection caps. + +Add pure dashboard derivation for freshness, filters/grouping and completion +sections. Freshness uses source activity timestamps rather than fetch times or +thread updatedAt (which can include cosmetic changes). Old observations mean +quiet reporting, never failed/stalled work. Verification extracted from labeled +output remains agent-reported, not independently verified. Full output remains +accessible in BB through Open; the plugin keeps its bounded sanitized preview. + +Enrich global executions with project names/git remotes using bounded public +projects.get calls for observed project IDs. Missing metadata is explicit and +must not prevent native observation. Filter only the already bounded snapshot; +the page states that matching sessions outside that window are not searched. +Attention stays visible independently of execution filters. + +Separate transport connection, successful snapshot refresh and server watcher +health. Record timestamps and generic error categories only. Quiet connections +are not errors. Catch asynchronous watcher errors and failed publication instead +of leaking rejected promises; expose degradation in the snapshot. + +Add a small version-keyed release dismissal store using plugin KV and bundled +release highlights. Use the package version as the single version source. A +release-notes control remains available after dismissal; storage failures stay +visible. No network, account, telemetry or external release fetching. + +Automated validation uses the official SDK fake server/app hosts, synthetic +data, separate dependencies and isolated storage. Real provider checks use a +verified separate BB data directory and loopback ports; never install a preview +into the production host. See [testing](vnext-testing.md) and +[candidate validation](validation-0.7.0.md). +No Lattice runtime integration or future orchestration/analytics is added. diff --git a/docs/vnext-testing.md b/docs/vnext-testing.md new file mode 100644 index 0000000..7b09b62 --- /dev/null +++ b/docs/vnext-testing.md @@ -0,0 +1,144 @@ +# Development launch and verification + +Use Node.js 24 and an isolated BB host. Version `0.7.0` is prepared on the PR +branch and is unpublished. See [candidate validation](validation-0.7.0.md) for +cross-platform results and their limits. Never install a review build over the +stable plugin, share its data directory, or copy its credentials into a test host. + +## Pull request CI + +The `Release validation` workflow runs for PRs targeting `main`, using official +GitHub-hosted Ubuntu and Windows runners with Node 24. Both run locked installation, +the full suite and typecheck. Ubuntu additionally prepares separate pinned BB +0.46.0 tools, runs the official full production build and `npm run verify:package`. +The inspector checks the actual tar archive, required assets, version metadata +and private/runtime-data exclusions. It leaves its temporary package outside Git. + +Permissions are `contents: read`; official Actions are pinned to commit revisions +and checkout credentials are not persisted. No secrets, self-hosted runners, +`pull_request_target`, release/tag jobs or sensitive artifact uploads are used. +Test failures remain failures: there are no unconditional retries or blanket +timeout increases. A clean local Windows install took 100 seconds importing the +large icon barrel and exceeded the existing 20-second app-hook limit. Vitest now +prebundles that data-only dependency once; real icon exports, app code and hook +limits are preserved. Fresh hosted timings validate this harness change. +See [dependency assessment](dependency-security-0.7.0.md). + +## Automated checks + +From the candidate checkout: + +```sh +npm ci --ignore-scripts --no-audit --no-fund +npm test +npm run typecheck +``` + +The suite uses the public SDK fake hosts, synthetic fixtures and isolated +storage. Vitest uses a serial thread pool for reliable Windows startup. A cold +import timeout has been observed: record the failed run, investigate it and +rerun unchanged checks; do not count a timeout as a pass. The development SDK +pin is 0.4.34. The official BB 0.46.0 builder stamps SDK 0.6.37; these serve +different roles and a build notice does not authorize upgrading the pin. + +## Guarded Windows helpers + +The helpers intentionally require this disposable workspace layout: + +```text +/outputs/agent-board-vnext/ candidate checkout +/work/bb-build-tools/ isolated official BB tools +/work/bb-validation-windows-data/ test data; preserve across restarts +``` + +Prepare tools outside the checkout, from ``: + +```powershell +npm install --prefix work\bb-build-tools --ignore-scripts --no-audit --no-fund bb-app@0.46.0 +``` + +Use an environment with working native SQLite dependencies. The native Windows +host was exercised successfully during this review; that is empirical coverage, +not a general platform-support promise. Prefer the supported Linux/WSL host +where available; do not change system networking or authentication to make a +validation environment work. + +From the candidate checkout, build and start in one terminal: + +```powershell +$env:BB_CLI='' +.\scripts\build-dev.ps1 +.\scripts\validation-host.ps1 start +``` + +After the test host is healthy, in a second terminal in the same checkout: + +```powershell +$env:BB_CLI='' +.\scripts\validation-host.ps1 install +.\scripts\validation-host.ps1 status +``` + +Open and choose Agent Board. Server/daemon ports are +49986/49987; telemetry and in-app updates are disabled for this host. Install +and stop verify the launcher marker, executable path, URL and process identity. +The build helper uses separate build data and restores the caller's environment. + +After all disposable tasks finish: + +```powershell +$env:BB_CLI='' +.\scripts\validation-host.ps1 stop +``` + +Keep the test data. Do not uninstall to test an upgrade: replacing the same +plugin identity preserves preferences, while uninstalling deletes them. + +## Guarded Linux helper + +`scripts/linux-validation-host.sh` uses only +`$HOME/.local/share/agent-board-vnext-validation/`, loopback ports 50886/50887, +and the source checkout containing the script. It refuses occupied start ports +and verifies the launcher identity before install/stop. Prepare its dedicated +`node24/` directory with an official Node 24 distribution verified against the +vendor checksums; keep it separate from system and production runtimes. + +```bash +runtime="$HOME/.local/share/agent-board-vnext-validation" +npm install --prefix "$runtime/bb-tools" --ignore-scripts --no-audit --no-fund bb-app@0.46.0 +(cd "$runtime/bb-tools" && npm rebuild better-sqlite3) +bash scripts/linux-validation-host.sh build +bash scripts/linux-validation-host.sh start +``` + +In another terminal, from the candidate checkout: + +```bash +bash scripts/linux-validation-host.sh install +bash scripts/linux-validation-host.sh status +``` + +Open . Stop only that test host after tasks finish: + +```bash +bash scripts/linux-validation-host.sh stop +``` + +The older `scripts/dev-host.sh` is a workspace-layout-specific WSL helper using +`/work/bb-linux-tools`, `/work/bb-wsl-data` and ports +49886/49887. It does not share selectors with the native Windows or Linux +validation hosts. Inspect selectors and prerequisites before using any helper. + +## Acceptance checks + +Use a disposable project for real provider work. Verify connected transport, +refresh/watch health, project/repository/provider/status filters and grouping, +attention preservation, completed results with explicit verification provenance, +controlled failure states, Open/Timeline navigation, active animation and +version-scoped announcement dismissal/reload/reopening. Test a narrow viewport +as well as desktop and reset temporary viewport overrides afterward. + +Agent-reported checks are not independent test evidence. Quiet reporting does +not prove a stall. Optional Fleet/Redteam fixtures do not replace nonempty live +adapter coverage. Save detailed local logs/screenshots in ignored `evidence/`; +do not publish private task text, paths, databases or credentials. diff --git a/hooks/useGlobalDashboard.ts b/hooks/useGlobalDashboard.ts index 20bb78b..e9defb7 100644 --- a/hooks/useGlobalDashboard.ts +++ b/hooks/useGlobalDashboard.ts @@ -1,6 +1,6 @@ // Global dashboard data hook. Core thread events drive immediate refreshes; // one slow bounded discovery check catches unrelated optional-plugin work. -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app"; import { BOARD_CHANGED_CHANNEL, @@ -25,6 +25,8 @@ export function useGlobalDashboard() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [tick, setTick] = useState(0); + const [lastSignalAt, setLastSignalAt] = useState(null); + const [lastRefreshAt, setLastRefreshAt] = useState(null); const snapshotRef = useRef(null); const inflight = useRef(false); /** @@ -52,6 +54,7 @@ export function useGlobalDashboard() { if (id !== requestId.current) return; snapshotRef.current = result; setSnapshot(result); + setLastRefreshAt(Date.now()); setError(null); }, (cause: unknown) => { @@ -79,7 +82,7 @@ export function useGlobalDashboard() { }, [refetch]); useRealtime(BOARD_CHANGED_CHANNEL, (payload) => { - if (isForGlobalDashboard(payload)) refetch(); + if (isForGlobalDashboard(payload)) { setLastSignalAt(Date.now()); refetch(); } }); const connectionState = useRealtimeConnectionState(); @@ -129,19 +132,10 @@ export function useGlobalDashboard() { return () => clearInterval(timer); }, [refetch]); - const hasLive = useMemo( - () => snapshot?.active.some((execution) => - execution.card.status === "running" || - execution.card.status === "starting" || - execution.card.status === "waiting", - ) ?? false, - [snapshot], - ); useEffect(() => { - if (!hasLive) return; const timer = setInterval(() => setTick((value) => value + 1), 1_000); return () => clearInterval(timer); - }, [hasLive]); + }, []); return { snapshot, @@ -153,5 +147,7 @@ export function useGlobalDashboard() { dismissAttention, clearDismissedAttentions, connectionState, + lastSignalAt, + lastRefreshAt, }; } diff --git a/lib/bb-source.ts b/lib/bb-source.ts index 019a951..c7e812a 100644 --- a/lib/bb-source.ts +++ b/lib/bb-source.ts @@ -15,6 +15,7 @@ // presentation labels, outputs. // - Statuses are mapped losslessly enough to drive column placement. import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { reportedCompletion } from "./dashboard-view"; import type { BoardActivity, BoardActivityFeed, @@ -206,6 +207,20 @@ export function latestPublicUpdate(rows: readonly TimelineRow[]): string | null return publicUpdatePreview(last.text); } +/** Work and public assistant timestamps; excludes reasoning/user/cosmetic events. */ +export function latestPublicActivityAt(rows: readonly TimelineRow[]): number | null { + const times: number[] = []; + for (const row of flattenRows(rows)) { + if (row.kind === "work" && !("presentation" in row && row.presentation?.suppress === true)) { + times.push(("completedAt" in row ? row.completedAt : null) ?? row.startedAt); + } else if (row.kind === "conversation" && "role" in row && row.role === "assistant") { + times.push(row.createdAt); + } + } + const valid = times.filter(at => Number.isFinite(at) && at >= 0); + return valid.length ? Math.max(...valid) : null; +} + /** Compact human label for a work kind (recent-activity rows). */ export function workKindLabel(workKind: string): string { switch (workKind) { @@ -499,6 +514,8 @@ export function normalizeThread( phaseTitle: null, promptPreview: null, activity: latestActivity(facts.timeline.rows), + lastActivityAt: latestPublicActivityAt(facts.timeline.rows), + ...reportedCompletion(facts.output ?? delegation?.output), outputPreview: previewText(facts.output ?? delegation?.output ?? null, BOARD_LIMITS.outputPreviewChars) ?? null, diff --git a/lib/board-model.ts b/lib/board-model.ts index a43005c..51b29cc 100644 --- a/lib/board-model.ts +++ b/lib/board-model.ts @@ -203,7 +203,8 @@ export function formatElapsed(ms: number): string { /** * Live elapsed for a card between authoritative snapshots: use durationMs - * when the work finished, otherwise clock from startedAt against `now`. + * when the work finished, otherwise clock live work from startedAt against `now`. + * Terminal work without an authoritative end time has unknown duration. */ export function cardElapsedMs(card: BoardCard, now: number): number | null { if (card.durationMs !== null && card.durationMs !== undefined) return card.durationMs; @@ -211,5 +212,6 @@ export function cardElapsedMs(card: BoardCard, now: number): number | null { if (card.completedAt !== null && card.completedAt !== undefined) { return Math.max(0, card.completedAt - card.startedAt); } + if (["completed", "failed", "interrupted", "skipped"].includes(card.status)) return null; return Math.max(0, now - card.startedAt); } diff --git a/lib/collect.ts b/lib/collect.ts index bbcc8f4..124b1d2 100644 --- a/lib/collect.ts +++ b/lib/collect.ts @@ -10,7 +10,7 @@ // say so instead of lying by omission. import type { BbPluginApi } from "@get-bb/plugin-sdk"; import type { BoardCard, ExecutionTimeline, ExecutionTimelineInput, GlobalDashboardSnapshot } from "../contract/rpc"; -import { BOARD_LIMITS, columnForStatus, countCards, previewText } from "./board-model"; +import { BOARD_LIMITS, columnForStatus, countCards, previewText, redactOperationalText } from "./board-model"; import { activityFromEvents, normalizeThread, @@ -37,6 +37,19 @@ import { import { FleetSource, scopedFleetCards, addFleetGroups, fleetTimelineEvents, fleetActive } from "./fleet-source"; import { normalizeBbTimeline } from "./timeline-model"; +/** Remote label only, stripped of credentials/query/fragment; never a link. */ +export function safeRepositoryLabel(remote: string | null | undefined): string | null { + if (!remote) return null; + try { + const url = new URL(remote); + if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) return null; + return previewText(redactOperationalText(`${url.host}${url.pathname.replace(/\.git$/, "")}`), 240); + } catch { + const scp = /^(?:[^@\s]+@)?([\w.-]+):([\w./-]+?)(?:\.git)?$/.exec(remote); + return scp ? previewText(redactOperationalText(`${scp[1]}/${scp[2]}`), 240) : null; + } +} + /** Output of board_snapshot (shape matches contract/rpc.ts). */ export interface BoardSnapshot { root: ReturnType; @@ -64,6 +77,8 @@ interface CacheEntry { at: number; value: V; } +type ProjectMetadata = { name: string | null; repository: string | null }; +export const PROJECT_METADATA_TIMEOUT_MS = 750; export class BoardCollector { private readonly sdk: Sdk; @@ -74,6 +89,8 @@ export class BoardCollector { private readonly outputCache = new Map>(); private readonly modelCache = new Map>(); private providersCache: CacheEntry> | null = null; + private readonly projectCache = new Map>(); + private readonly projectRequests = new Map>(); private readonly inflight = new Map; generation: number }>(); private readonly snapshotCache = new Map>(); private globalSnapshotCache: CacheEntry | null = null; @@ -427,6 +444,33 @@ export class BoardCollector { return snapshot; } + private async projectMetadata(projectId: string): Promise { + const hit = this.projectCache.get(projectId); + if (hit && this.now() - hit.at < 30_000) return hit.value; + let request = this.projectRequests.get(projectId); + if (!request) { + // Retain physical requests after timeout; a later refresh cannot overlap + // an unresponsive metadata RPC. Bound that registry independently. + if (this.projectRequests.size >= 64) return null; + request = Promise.resolve().then(() => this.sdk.projects.get({ projectId })).then(project => { + const value = { name: previewText(redactOperationalText(project.name), 120), repository: safeRepositoryLabel(project.gitRemoteUrl) }; + this.projectCache.delete(projectId); + this.projectCache.set(projectId, { at: this.now(), value }); + if (this.projectCache.size > 64) this.projectCache.delete(this.projectCache.keys().next().value!); + return value; + }); + this.projectRequests.set(projectId, request); + const current = request; + const settled = () => { if (this.projectRequests.get(projectId) === current) this.projectRequests.delete(projectId); }; + void request.then(settled, settled); + } + let timer: ReturnType | undefined; + try { + return await Promise.race([request, new Promise(resolve => { timer = setTimeout(() => resolve(null), PROJECT_METADATA_TIMEOUT_MS); })]); + } catch { return null; } + finally { if (timer) clearTimeout(timer); } + } + private async collectGlobal( fresh: boolean, dismissedKeys?: ReadonlySet, @@ -460,12 +504,25 @@ export class BoardCollector { const fleetSnapshot = fleetResult.status === "fulfilled" ? await this.fleet.collect() : null; const combined = fleetSnapshot ? addFleetGroups(groups, fleetSnapshot) : groups; if (fleetSnapshot) partial ||= fleetSnapshot.truncated; - return buildGlobalDashboard(combined, { + const dashboard = buildGlobalDashboard(combined, { now: this.now(), partial, unavailableSources: unavailable, dismissedKeys, }); + // Public metadata enrichment is bounded to visible projects, concurrency 4. + const metadata = new Map(); + const projectIds = [...new Set([...dashboard.active, ...dashboard.recent].map(e => e.projectId).filter((id): id is string => id !== null))]; + await mapWithConcurrency(projectIds, 4, async projectId => { + const project = await this.projectMetadata(projectId); + if (project) metadata.set(projectId, project); + }); + for (const execution of [...dashboard.active, ...dashboard.recent]) { + const project = execution.projectId ? metadata.get(execution.projectId) : undefined; + execution.projectName = project?.name ?? null; + execution.repository = project?.repository ?? null; + } + return dashboard; } private async collectGlobalBb(): Promise { diff --git a/lib/dashboard-view.ts b/lib/dashboard-view.ts new file mode 100644 index 0000000..f0268f1 --- /dev/null +++ b/lib/dashboard-view.ts @@ -0,0 +1,75 @@ +import type { BoardCard, GlobalExecution, GlobalDashboardSnapshot } from "../contract/rpc"; +import { previewText, redactOperationalText } from "./board-model"; + +export const QUIET_ACTIVITY_MS = 120_000; +export function activityFreshness(card: BoardCard, now: number) { + const at = card.lastActivityAt ?? card.activity?.at ?? null; + if (at === null || !Number.isFinite(at) || at < 0 || at > now + 5_000) return { state: "unknown" as const, at: null, ageMs: null }; + const ageMs = Math.max(0, now - at); + return { state: ageMs >= QUIET_ACTIVITY_MS ? "quiet" as const : "recent" as const, at, ageMs }; +} + +export type FilterField = "project" | "repository" | "provider" | "status"; +export type DashboardFilters = Record; +export const EMPTY_FILTERS: DashboardFilters = { project: "", repository: "", provider: "", status: "" }; +// Separate sentinels prevent a real ID/remote from colliding with Unknown. +export function filterValue(execution: GlobalExecution, field: FilterField): string { + const value = field === "project" ? execution.projectId : field === "repository" ? execution.repository : field === "provider" ? execution.card.providerId : execution.card.status; + return value ? `value:${value}` : "unknown"; +} +export function filterLabel(execution: GlobalExecution, field: FilterField): string { + if (field === "project") return execution.projectName ?? execution.projectId ?? "Unknown project"; + if (field === "repository") return execution.repository ?? "Unknown repository"; + if (field === "provider") return execution.card.providerLabel ?? execution.card.providerId ?? "Unknown provider"; + return execution.card.status; +} +export function filterOptions(executions: readonly GlobalExecution[], field: FilterField) { + const values = new Map(); + for (const execution of executions) values.set(filterValue(execution, field), filterLabel(execution, field)); + return [...values].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)); +} +export function matchesFilters(execution: GlobalExecution, filters: DashboardFilters): boolean { + return (Object.keys(filters) as FilterField[]).every(field => !filters[field] || filters[field] === filterValue(execution, field)); +} +export function filterDashboard(snapshot: GlobalDashboardSnapshot, filters: DashboardFilters) { + return { active: snapshot.active.filter(e => matchesFilters(e, filters)), recent: snapshot.recent.filter(e => matchesFilters(e, filters)) }; +} +export function groupExecutions(executions: readonly GlobalExecution[], field: FilterField | "none") { + if (field === "none") return [{ key: "all", label: null, executions: [...executions] }]; + const groups = new Map(); + for (const execution of executions) { + const key = filterValue(execution, field); + const group = groups.get(key) ?? { key, label: filterLabel(execution, field), executions: [] }; + group.executions.push(execution); groups.set(key, group); + } + return [...groups.values()].sort((a, b) => a.label.localeCompare(b.label)); +} + +/** Parse explicit labels only. Output prose is a report, never test evidence. */ +export function reportedCompletion(text: string | null | undefined) { + // Only inspect a bounded tail; final result/check labels normally end a response. + const tail = typeof text === "string" ? redactOperationalText(text.slice(-16_384)) : ""; + const labels = /(?:^|\s)(?:#{1,6}\s*)?(?:\*\*)?(result|verification|validation|tests(?: executed| run)?|checks)(?:\*\*)?\s*:(?:\*\*)?\s*([\s\S]*?)(?=\n|\s+(?:\*\*)?(?:result|verification|validation|tests(?: executed| run)?|checks)(?:\*\*)?\s*:|$)/gi; + let completionResult: string | null = null; + const reportedVerification: string[] = []; + for (const match of tail.matchAll(labels)) { + const value = previewText(match[2], 240); + if (!value) continue; + if (match[1]!.toLowerCase() === "result") completionResult = value; + else if (reportedVerification.length < 4) reportedVerification.push(value); + } + return { completionResult, reportedVerification }; +} + +export function completionSummary(card: BoardCard) { + const text = card.outputPreview?.trim() ?? ""; + const fallback = reportedCompletion(text); + const reportedChecks = card.reportedVerification ?? fallback.reportedVerification; + return { + outcome: card.status, + result: card.completionResult ?? fallback.completionResult ?? (text || "No result summary supplied by this source."), + failure: card.errorPreview, + reportedChecks, + originalPreview: text, + }; +} diff --git a/lib/global-model.ts b/lib/global-model.ts index 0f68a5d..e35da26 100644 --- a/lib/global-model.ts +++ b/lib/global-model.ts @@ -96,6 +96,7 @@ export function buildGlobalExecution(group: GlobalExecutionGroup): GlobalExecuti (startedAt !== null && completedAt !== null ? Math.max(0, completedAt - startedAt) : null); const sourceLabel = group.source === "bb" ? "BB" : group.source === "ollama-fleet" ? "Ollama Fleet" : "Redteam"; const latestPublicUpdate = pickLatestPublicUpdate(group.cards); + const activityTimes = [...group.cards, ...workers].map(c => c.lastActivityAt ?? c.activity?.at).filter((at): at is number => typeof at === "number" && Number.isFinite(at)); const card: BoardCard = { key: `global:${group.key}`, @@ -120,7 +121,10 @@ export function buildGlobalExecution(group: GlobalExecutionGroup): GlobalExecuti // A global aggregation never broadens prompt visibility. promptPreview: null, activity: primary?.activity ?? null, + lastActivityAt: activityTimes.length ? Math.max(...activityTimes) : null, outputPreview: primary?.outputPreview ?? null, + completionResult: primary?.completionResult ?? null, + ...(primary?.reportedVerification !== undefined ? { reportedVerification: primary.reportedVerification } : {}), latestPublicUpdate, errorPreview: primary?.errorPreview ?? null, tokens: primary?.tokens ?? null, diff --git a/lib/releases.ts b/lib/releases.ts new file mode 100644 index 0000000..636c0ce --- /dev/null +++ b/lib/releases.ts @@ -0,0 +1,36 @@ +import manifest from "../package.json"; +import type { KvLike } from "./dismissals"; + +export const CURRENT_RELEASE = { + version: manifest.version, + title: "A clearer view of agent activity", + highlights: [ + "See recent or quiet reporting without treating long tasks as stalled.", + "Filter and group visible work by project, repository, provider and status.", + "Review completion results, failures and agent-reported verification.", + "Check connection, refresh and watcher health; empty attention stays compact.", + ], +}; +const STORAGE_KEY = "release-dismissals"; +export class ReleaseAnnouncementStore { + private writing: Promise = Promise.resolve(); + constructor(private readonly kv: KvLike, private readonly release = CURRENT_RELEASE) {} + private async versions(): Promise { + const value = await this.kv.get(STORAGE_KEY); + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length <= 80).slice(-20) : []; + } + async snapshot() { + await this.writing.catch(() => {}); + return { ...this.release, dismissed: (await this.versions()).includes(this.release.version) }; + } + async dismiss(version: string) { + if (version !== this.release.version) throw new Error("Plugin version changed. Reopen release notes and try again."); + const write = this.writing.catch(() => {}).then(async () => { + const versions = await this.versions(); + await this.kv.set(STORAGE_KEY, [...versions.filter(v => v !== version), version].slice(-20)); + return { dismissed: true }; + }); + this.writing = write; + return write; + } +} diff --git a/lib/watch.ts b/lib/watch.ts index 13a306b..bafab03 100644 --- a/lib/watch.ts +++ b/lib/watch.ts @@ -182,6 +182,12 @@ export class BoardWatcher { /** Terminal signal contributed to the current dirty set. */ private dirtyTerminal = false; private disposed = false; + private unavailable = false; + private degraded = false; + private startedAt = 0; + private lastEventAt: number | null = null; + private lastPublishedAt: number | null = null; + private lastErrorAt: number | null = null; private readonly watchedTtlMs: number; private readonly debounceMs: number; private readonly lookupGapMs: number; @@ -193,10 +199,23 @@ export class BoardWatcher { this.lookupGapMs = deps.lookupGapMs ?? 2_000; this.reconcileDelayMs = deps.reconcileDelayMs ?? 1_500; this.clock = deps.now ?? (() => Date.now()); + this.startedAt = this.clock(); } private readonly clock: () => number; + health() { + return { status: this.disposed ? "disposed" as const : this.unavailable ? "unavailable" as const : this.degraded ? "degraded" as const : "watching" as const, + startedAt: this.startedAt, lastEventAt: this.lastEventAt, lastPublishedAt: this.lastPublishedAt, lastErrorAt: this.lastErrorAt }; + } + recordError(): void { if (!this.disposed) { this.lastErrorAt = this.clock(); this.degraded = true; } } + markUnavailable(): void { this.unavailable = true; this.recordError(); } + private publish(payload: { roots: string[]; global?: boolean }): boolean { + if (this.disposed) return false; + try { this.deps.publish(payload); this.lastPublishedAt = this.clock(); this.degraded = false; return true; } + catch { this.recordError(); return false; } + } + /** Called by the RPC handler whenever a board is fetched for a root. */ touch(rootThreadId: string, threads: string[], projectId: string | null): void { if (this.disposed) return; @@ -264,6 +283,7 @@ export class BoardWatcher { async handleChanged(event: ThreadChangedEvent): Promise { if (this.disposed) return; if (!isRelevantThreadChange(event)) return; + this.lastEventAt = this.clock(); if (isTerminalThreadChange(event)) this.dirtyTerminal = true; this.markGlobalDirty(); if (event.id === undefined) { @@ -296,6 +316,7 @@ export class BoardWatcher { kind: LifecycleKind = "other", ): Promise { if (this.disposed) return; + this.lastEventAt = this.clock(); if (isTerminalLifecycleKind(kind)) this.dirtyTerminal = true; this.markGlobalDirty(); for (const root of this.candidatesFor(thread.id, thread.projectId ?? null)) { @@ -376,7 +397,7 @@ export class BoardWatcher { this.dirty.clear(); this.dirtyGlobal = false; const payload = { roots, ...(global ? { global: true } : {}) }; - this.deps.publish(payload); + this.publish(payload); // Bounded terminal-state self-correction: ONE delayed re-publish of the // same payload, so open boards re-read BB after any pre-terminal // in-flight fetch settled. Terminal threads emit no further signals, so @@ -391,7 +412,7 @@ export class BoardWatcher { this.reconcileTimer = setTimeout(() => { this.reconcileTimer = null; if (this.disposed) return; - this.deps.publish(payload); + this.publish(payload); }, this.reconcileDelayMs); if ( typeof this.reconcileTimer === "object" && @@ -425,42 +446,43 @@ export class BoardWatcher { /** Wire the watcher to the plugin API; returns the unsubscribe handle. */ export function attachWatcher(bb: BbPluginApi, watcher: BoardWatcher): () => void { - const unsubscribe = bb.sdk.subscribe({ + let unsubscribe = () => {}; + try { unsubscribe = bb.sdk.subscribe({ event: "thread:changed", callback: (event) => { - void watcher.handleChanged(event); + void watcher.handleChanged(event).catch(() => watcher.recordError()); }, - }); + }); } catch { watcher.markUnavailable(); } // bb.events.on listeners live for the plugin's lifetime (no dispose handle). bb.events.on("thread.created", (payload) => { - void watcher.handleLifecycle(payload.thread, "created"); + void watcher.handleLifecycle(payload.thread, "created").catch(() => watcher.recordError()); }); bb.events.on("thread.active", (payload) => { - void watcher.handleLifecycle(payload.thread, "active"); + void watcher.handleLifecycle(payload.thread, "active").catch(() => watcher.recordError()); }); bb.events.on("thread.idle", (payload) => { - void watcher.handleLifecycle(payload.thread, "idle"); + void watcher.handleLifecycle(payload.thread, "idle").catch(() => watcher.recordError()); }); bb.events.on("thread.failed", (payload) => { - void watcher.handleLifecycle(payload.thread, "failed"); + void watcher.handleLifecycle(payload.thread, "failed").catch(() => watcher.recordError()); }); bb.events.on("thread.archived", (payload) => { - void watcher.handleLifecycle(payload.thread, "archived"); + void watcher.handleLifecycle(payload.thread, "archived").catch(() => watcher.recordError()); }); bb.events.on("thread.deleted", (payload) => { - void watcher.handleLifecycle(payload.thread, "deleted"); + void watcher.handleLifecycle(payload.thread, "deleted").catch(() => watcher.recordError()); }); // Newer event names may not exist on older runtimes (which throw at // registration). Subscribe opportunistically: the thread:changed channel // already covers queue and failure transitions. onOptionalLifecycleEvent(bb, "turn.failed", (payload: { threadId: string }) => { - void watcher.handleLifecycle({ id: payload.threadId }, "turn-failed"); + void watcher.handleLifecycle({ id: payload.threadId }, "turn-failed").catch(() => watcher.recordError()); }); onOptionalLifecycleEvent(bb, "message.queued", (payload: { entry: { threadId: string } }) => { - void watcher.handleLifecycle({ id: payload.entry.threadId }, "other"); + void watcher.handleLifecycle({ id: payload.entry.threadId }, "other").catch(() => watcher.recordError()); }); onOptionalLifecycleEvent(bb, "message.dispatched", (payload: { entry: { threadId: string } }) => { - void watcher.handleLifecycle({ id: payload.entry.threadId }, "other"); + void watcher.handleLifecycle({ id: payload.entry.threadId }, "other").catch(() => watcher.recordError()); }); return unsubscribe; } diff --git a/package-lock.json b/package-lock.json index 4d71ebb..e24972a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bb-plugin-agent-board", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-agent-board", - "version": "0.6.1", + "version": "0.7.0", "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", @@ -36,7 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cron-parser": "^5.5.0", - "hono": "^4.11.9", + "hono": "^4.13.7", "jsdom": "^30.0.1", "sonner": "^1.7.4", "tailwind-merge": "^3.4.0", @@ -45,8 +45,8 @@ "vitest": "^5.0.0" }, "engines": { - "bb": ">=0.41", - "bbPluginSdk": ">=0.4.34" + "bb": ">=0.40", + "bbPluginSdk": ">=0.4.21" }, "license": "MIT" }, @@ -2600,9 +2600,9 @@ } }, "node_modules/hono": { - "version": "4.13.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", - "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", "dev": true, "license": "MIT", "engines": { @@ -3779,9 +3779,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4025,9 +4025,9 @@ } }, "node_modules/undici": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz", - "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 774eaa7..09991ce 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,29 @@ { "name": "bb-plugin-agent-board", - "version": "0.6.1", + "version": "0.7.0", "type": "module", + "files": [ + "app.tsx", + "app.css", + "server.ts", + "components/", + "contract/", + "hooks/", + "lib/", + "tsconfig.json", + "dist/", + "LICENSE", + "THIRD_PARTY_NOTICES.md", + "DEPENDENCY_LICENSES.md", + "PLUGIN_OVERVIEW.md", + "CHANGELOG.md", + "RELEASE_NOTES.md", + "docs/upgrade-0.7.0.md" + ], "scripts": { "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "verify:package": "node scripts/verify-package.mjs" }, "engines": { "bb": ">=0.40", @@ -48,7 +67,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cron-parser": "^5.5.0", - "hono": "^4.11.9", + "hono": "^4.13.7", "jsdom": "^30.0.1", "sonner": "^1.7.4", "tailwind-merge": "^3.4.0", @@ -56,6 +75,10 @@ "vaul": "^1.1.2", "vitest": "^5.0.0" }, + "overrides": { + "undici": "8.10.2", + "source-map-js": "1.2.2" + }, "allowScripts": { "better-sqlite3": true, "node-pty": true, diff --git a/scripts/build-dev.ps1 b/scripts/build-dev.ps1 new file mode 100644 index 0000000..914600c --- /dev/null +++ b/scripts/build-dev.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = 'Stop' +$taskRoot = Split-Path -Parent $PSScriptRoot +$taskWorkspace = Split-Path -Parent (Split-Path -Parent $taskRoot) +$taskCli = Join-Path $taskWorkspace 'work\bb-build-tools\node_modules\bb-app\dist\bb.js' +$taskData = Join-Path $taskWorkspace 'work\bb-dev-data' +if (-not (Test-Path -LiteralPath $taskCli -PathType Leaf)) { + throw 'Task-local BB build tool is missing. See docs/vnext-testing.md.' +} +$taskOldData = $env:BB_DATA_DIR +$taskOldUrl = $env:BB_SERVER_URL +$taskOldTelemetry = $env:BB_TELEMETRY +Push-Location -LiteralPath $taskRoot +try { + $env:BB_DATA_DIR = $taskData + $env:BB_SERVER_URL = 'http://127.0.0.1:49886' + $env:BB_TELEMETRY = 'false' + node $taskCli plugin build + if ($LASTEXITCODE -ne 0) { throw "BB build failed ($LASTEXITCODE)." } +} finally { + $env:BB_DATA_DIR = $taskOldData + $env:BB_SERVER_URL = $taskOldUrl + $env:BB_TELEMETRY = $taskOldTelemetry + Pop-Location +} diff --git a/scripts/dev-host.sh b/scripts/dev-host.sh new file mode 100644 index 0000000..0f95a6d --- /dev/null +++ b/scripts/dev-host.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +WORKSPACE="$(cd -- "$ROOT/../.." && pwd -P)" +TOOLS="$WORKSPACE/work/bb-linux-tools/node_modules/bb-app/dist" +# Only this task's host. These selectors deliberately cannot inherit a +# production data directory or server URL from the caller's environment. +export BB_DATA_DIR="$WORKSPACE/work/bb-wsl-data" +export BB_SERVER_URL="http://127.0.0.1:49886" +export BB_TELEMETRY=false +if [[ -x "$HOME/.nvm/versions/node/v24.20.0/bin/node" ]]; then + export PATH="$HOME/.nvm/versions/node/v24.20.0/bin:$PATH" +fi +NODE="$(command -v node || true)" +if [[ -z "$NODE" || ! -f "$TOOLS/bb-app.js" ]]; then + printf '%s\n' 'Linux Node and task-local bb-app 0.46.0 are required; see docs/vnext-testing.md.' >&2 + exit 1 +fi +cd -- "$ROOT" +case "${1:-start}" in + start) + exec "$NODE" "$TOOLS/bb-app.js" --data-dir "$BB_DATA_DIR" --server-bind-host 127.0.0.1 --server-port 49886 --host-daemon-port 49887 --no-in-app-updates start ;; + stop) + exec "$NODE" "$TOOLS/bb-app.js" --data-dir "$BB_DATA_DIR" stop ;; + build) + exec "$NODE" "$TOOLS/bb.js" plugin build ;; + install) + # Refuse installation unless the task's isolated server is reachable. + "$NODE" --input-type=module - "$BB_DATA_DIR/bb-app-runtime.json" "$TOOLS/bb-app.js" <<'JS' +import fs from "node:fs"; +const runtime = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const expected = fs.realpathSync(process.argv[3]); +if (runtime.serverUrl !== process.env.BB_SERVER_URL || fs.realpathSync(runtime.entryPath) !== expected) throw new Error("Isolated host identity does not match"); +process.kill(runtime.pid, 0); +const argv = fs.readFileSync(`/proc/${runtime.pid}/cmdline`, "utf8").split("\0"); +if (!argv.includes(expected)) throw new Error("Isolated launcher process does not match"); +JS + curl --silent --show-error --fail --max-time 5 "$BB_SERVER_URL/api/health" >/dev/null + exec "$NODE" "$TOOLS/bb.js" plugin install "$ROOT" --yes ;; + status) + exec "$NODE" "$TOOLS/bb.js" plugin list ;; + *) printf '%s\n' 'Usage: bash scripts/dev-host.sh start|stop|build|install|status' >&2; exit 1 ;; +esac diff --git a/scripts/linux-validation-host.sh b/scripts/linux-validation-host.sh new file mode 100644 index 0000000..96984ee --- /dev/null +++ b/scripts/linux-validation-host.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +RUNTIME="$HOME/.local/share/agent-board-vnext-validation" +TOOLS="$RUNTIME/bb-tools/node_modules/bb-app/dist" +export PATH="$RUNTIME/node24/bin:$HOME/.local/bin:$PATH" +export BB_DATA_DIR="$RUNTIME/data" +export BB_SERVER_URL="http://127.0.0.1:50886" +export BB_TELEMETRY=false +if [[ ! -x "$RUNTIME/node24/bin/node" || ! -f "$TOOLS/bb-app.js" ]]; then + printf '%s\n' 'Isolated Node/BB tools are missing.' >&2; exit 1 +fi +assert_host() { + node --input-type=module - "$BB_DATA_DIR/bb-app-runtime.json" "$TOOLS/bb-app.js" <<'JS' +import fs from 'node:fs'; +const marker = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const entry = fs.realpathSync(process.argv[3]); +if (marker.serverUrl !== process.env.BB_SERVER_URL || fs.realpathSync(marker.entryPath) !== entry) throw Error('Isolated host marker mismatch'); +const args = fs.readFileSync(`/proc/${marker.pid}/cmdline`,'utf8').split('\0'); +if (!args.includes(entry) || !args.includes(process.env.BB_DATA_DIR)) throw Error('Isolated launcher process mismatch'); +JS +} +cd -- "$ROOT" +case "${1:-start}" in + start) + if ss -H -ltn '( sport = :50886 or sport = :50887 )' | grep -q .; then + printf '%s\n' 'Validation ports are occupied; no process was changed.' >&2; exit 1 + fi + exec node "$TOOLS/bb-app.js" --data-dir "$BB_DATA_DIR" --server-bind-host 127.0.0.1 --server-port 50886 --host-daemon-port 50887 --no-in-app-updates start ;; + stop) assert_host; exec node "$TOOLS/bb-app.js" --data-dir "$BB_DATA_DIR" stop ;; + build) exec node "$TOOLS/bb.js" plugin build ;; + install) + assert_host + curl --silent --show-error --fail --max-time 5 "$BB_SERVER_URL/api/health" >/dev/null + exec node "$TOOLS/bb.js" plugin install "$ROOT" --yes ;; + status) assert_host; exec node "$TOOLS/bb.js" plugin list ;; + *) printf '%s\n' 'Usage: bash scripts/linux-validation-host.sh start|stop|build|install|status' >&2; exit 1 ;; +esac diff --git a/scripts/validation-host.ps1 b/scripts/validation-host.ps1 new file mode 100644 index 0000000..0076f2d --- /dev/null +++ b/scripts/validation-host.ps1 @@ -0,0 +1,41 @@ +param([ValidateSet('start', 'stop', 'install', 'status')][string]$Action = 'start') +$ErrorActionPreference = 'Stop' +$taskRoot = Split-Path -Parent $PSScriptRoot +$taskWorkspace = Split-Path -Parent (Split-Path -Parent $taskRoot) +$taskTools = Join-Path $taskWorkspace 'work\bb-build-tools\node_modules\bb-app\dist' +$taskApp = Join-Path $taskTools 'bb-app.js' +$taskCli = Join-Path $taskTools 'bb.js' +$taskData = Join-Path $taskWorkspace 'work\bb-validation-windows-data' +$taskUrl = 'http://127.0.0.1:49986' +if (-not (Test-Path -LiteralPath $taskApp -PathType Leaf)) { throw 'Task-local BB 0.46.0 is missing; see docs/vnext-testing.md.' } +function Assert-ValidationHost { + $taskRuntime = Get-Content -Raw -LiteralPath (Join-Path $taskData 'bb-app-runtime.json') | ConvertFrom-Json + if ($taskRuntime.serverUrl -ne $taskUrl -or [IO.Path]::GetFullPath($taskRuntime.entryPath) -ne [IO.Path]::GetFullPath($taskApp)) { throw 'Validation host identity does not match.' } + $taskProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $($taskRuntime.pid)" + if (-not $taskProcess -or -not $taskProcess.CommandLine.Contains($taskApp) -or -not $taskProcess.CommandLine.Contains($taskData)) { throw 'Validation launcher process does not match.' } +} +$taskPreviousData = $env:BB_DATA_DIR +$taskPreviousUrl = $env:BB_SERVER_URL +$taskPreviousTelemetry = $env:BB_TELEMETRY +Push-Location -LiteralPath $taskRoot +try { + $env:BB_DATA_DIR = $taskData + $env:BB_SERVER_URL = $taskUrl + $env:BB_TELEMETRY = 'false' + switch ($Action) { + start { node $taskApp --data-dir $taskData --server-bind-host 127.0.0.1 --server-port 49986 --host-daemon-port 49987 --no-in-app-updates start } + stop { Assert-ValidationHost; node $taskApp --data-dir $taskData stop } + install { + Assert-ValidationHost + $taskHealth = Invoke-RestMethod -Uri "$taskUrl/api/health" -TimeoutSec 5 + node $taskCli plugin install $taskRoot --yes + } + status { Assert-ValidationHost; node $taskCli plugin list } + } + if ($LASTEXITCODE -ne 0) { throw "BB command failed ($LASTEXITCODE)." } +} finally { + $env:BB_DATA_DIR = $taskPreviousData + $env:BB_SERVER_URL = $taskPreviousUrl + $env:BB_TELEMETRY = $taskPreviousTelemetry + Pop-Location +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..bf459bd --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const readJson = file => JSON.parse(fs.readFileSync(file, 'utf8')); +const pkg = readJson(path.join(root, 'package.json')); +const lock = readJson(path.join(root, 'package-lock.json')); +assert.equal(pkg.name, 'bb-plugin-agent-board'); +assert.equal(pkg.version, lock.version); +assert.equal(pkg.version, lock.packages[''].version); +assert.deepEqual(pkg.dependencies, lock.packages[''].dependencies); +assert.deepEqual(pkg.devDependencies, lock.packages[''].devDependencies); +assert.deepEqual(pkg.engines, lock.packages[''].engines); +assert.equal(pkg.bb.server, './server.ts'); +assert.equal(pkg.bb.app, './app.tsx'); + +const temp = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP || os.tmpdir(), 'agent-board-pack-')); +assert(process.env.npm_execpath, 'Run through npm run verify:package'); +const [pack] = JSON.parse(execFileSync(process.execPath, + [process.env.npm_execpath, 'pack', '--json', '--ignore-scripts', '--pack-destination', temp], + { cwd: root, encoding: 'utf8' })); +assert.equal(pack.name, pkg.name); +assert.equal(pack.version, pkg.version); +assert.deepEqual(pack.bundled, []); +const archive = path.join(temp, pack.filename); +const entries = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split(/\r?\n/); +const modes = execFileSync('tar', ['-tvzf', archive], { encoding: 'utf8' }).trim().split(/\r?\n/); +assert.equal(entries.length, pack.entryCount); +assert.equal(new Set(entries).size, entries.length); +assert(modes.every(line => line.startsWith('-')), 'Only regular files may be packed'); +const allowed = new Set(pack.files.map(file => `package/${file.path}`)); +for (const entry of entries) { + assert(allowed.has(entry), `Unexpected archive member: ${entry}`); + assert(entry.startsWith('package/') && !entry.includes('\\') && !entry.split('/').includes('..')); + const name = entry.slice('package/'.length); + assert(!/(^|\/)(?:\.git|\.github|\.ssh|\.aws|\.codex|\.agents|node_modules|tests|scripts|evidence|coverage|\.cache|\.vite)(\/|$)/.test(name), name); + assert(!/(^|\/)(?:\.env(?:\..*)?|\.npmrc|auth\.json|auth-secret|PHASE1_[^/]*)(\/|$)/.test(name), name); + assert(!/\.(?:sqlite\w*|db(?:-\w+)?|log|jpg|png|tgz)$/i.test(name), name); +} +const required = [ + 'package.json', 'server.ts', 'app.tsx', 'app.css', 'contract/rpc.ts', + 'dist/server.js', 'dist/server.js.map', 'dist/server.meta.json', + 'dist/app.js', 'dist/app.css', 'dist/app.meta.json', 'dist/package.json', + 'README.md', 'LICENSE', 'RELEASE_NOTES.md', 'CHANGELOG.md', + 'PLUGIN_OVERVIEW.md', 'docs/upgrade-0.7.0.md', +]; +for (const name of required) assert(allowed.has(`package/${name}`), `Missing runtime/document asset: ${name}`); + +fs.mkdirSync(path.join(temp, 'extracted')); +execFileSync('tar', ['-xzf', archive, '-C', path.join(temp, 'extracted')]); +for (const file of pack.files) { + const packed = fs.readFileSync(path.join(temp, 'extracted', 'package', file.path)); + assert(packed.equals(fs.readFileSync(path.join(root, file.path))), `Stale asset: ${file.path}`); + assert(!/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/.test(packed.toString('utf8')), `Credential pattern: ${file.path}`); +} +for (const side of ['app', 'server']) { + const meta = readJson(path.join(temp, 'extracted', 'package', 'dist', `${side}.meta.json`)); + assert.equal(meta.pluginId, 'agent-board'); + assert.equal(meta.pluginVersion, pkg.version); + assert.equal(meta.artifactFormatVersion, 1); + assert.equal(meta.builtWith.bbVersion, '0.46.0'); +} +const sha256 = createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); +console.log(JSON.stringify({ pluginId: 'agent-board', version: pkg.version, files: entries.length, size: pack.size, sha256 })); +if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Verified **agent-board ${pkg.version}**: ${entries.length} packed files; fresh frontend/backend assets; SHA-256 \`${sha256}\`.\n`); +} diff --git a/server.ts b/server.ts index 883e9e7..c77f1a1 100644 --- a/server.ts +++ b/server.ts @@ -3,8 +3,8 @@ // The default export is a factory that receives the plugin API. The board is // derived observability: on each board_snapshot request it fans out to // bb.sdk (bounded), normalizes what BB reports into BoardCards, and returns -// a snapshot. BB is the only source of truth; the plugin keeps no durable -// state and only small ephemeral caches. +// a snapshot. Sources remain authoritative; only attention/release dismissal +// preferences are durable. Collection and watcher state are ephemeral caches. import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; import { rpcContract } from "./contract/rpc"; import { BoardCollector } from "./lib/collect"; @@ -12,6 +12,7 @@ import { attachWatcher, BoardWatcher } from "./lib/watch"; import { FleetActivePoller } from "./lib/fleet-poll"; import { RedteamActivePoller } from "./lib/redteam-poll"; import { DismissalStore, attentionDismissalKey } from "./lib/dismissals"; +import { ReleaseAnnouncementStore } from "./lib/releases"; export { BOARD_CHANGED_CHANNEL } from "./contract/rpc"; @@ -22,6 +23,7 @@ export default async function plugin(bb: BbPluginApi) { // Presentation-only attention dismissals (one bounded kv row in bb.db, // surviving plugin reloads; never mutates any underlying source state). const dismissals = new DismissalStore(bb.storage.kv); + const releases = new ReleaseAnnouncementStore(bb.storage.kv); const watcher = new BoardWatcher({ publish: (payload) => { collector.invalidate(); @@ -61,6 +63,8 @@ export default async function plugin(bb: BbPluginApi) { // BB threads/workflows/plans/delegations plus optional adapter cards share // one source-discriminated normalized contract. bb.rpc.register(rpcContract, { + release_announcement: () => releases.snapshot(), + dismiss_release_announcement: ({ version }) => releases.dismiss(version), board_snapshot: async ({ threadId, fresh }) => { const snapshot = await collector.snapshot(threadId, { fresh }); // Feed the watcher the observed tree so change signals can be @@ -90,7 +94,7 @@ export default async function plugin(bb: BbPluginApi) { watcher.touchGlobal(); redteamPoller.observeGlobal(collector.isGlobalRedteamActive()); fleetPoller.observe(collector.isFleetActive()); - return snapshot; + return { ...snapshot, monitoring: watcher.health() }; }, dismiss_attention: async ({ executionKey, type, updatedAt }) => { const dismissed = await dismissals.dismiss( diff --git a/tests/bb-source.test.ts b/tests/bb-source.test.ts index 9b26526..6b8fb7d 100644 --- a/tests/bb-source.test.ts +++ b/tests/bb-source.test.ts @@ -4,6 +4,7 @@ import { activityFromEvents, latestActivity, latestPublicUpdate, + latestPublicActivityAt, normalizeThread, recentActivityFeed, threadBoardStatus, @@ -129,6 +130,13 @@ function queuedMessage(fields: Record): QueuedMessageRow { } describe("thread card normalization", () => { + it("retains explicit bounded completion labels beyond the preview", () => { + const output = "Review details. ".repeat(100) + "\n**Result:** Boundary review completed\n**Tests:** not run (review only)"; + const root = normalizeThread(facts({ output }), ctx, null).find(c => c.kind === "thread")!; + expect(root.outputPreview!.length).toBeLessThanOrEqual(240); + expect(root.reportedVerification).toEqual(["not run (review only)"]); + expect(root.completionResult).toBe("Boundary review completed"); + }); it("renders the root thread as an active card with its latest activity", () => { const rows = [ workRow("command", { command: "npm test", status: "pending" }), @@ -820,6 +828,19 @@ function conversationRow( }; } +describe("public activity timestamps", () => { + it("excludes user, hidden thinking and cosmetic thread timestamps", () => { + const user = { ...(conversationRow("user", "private", 5) as Record), createdAt: 900 }; + const assistant = { ...(conversationRow("assistant", "public update", 3) as Record), createdAt: 300 }; + expect(latestPublicActivityAt([user, assistant] as never)).toBe(300); + expect(latestPublicActivityAt([user] as never)).toBeNull(); + }); + it("uses completed work timestamps and nested public activity", () => { + const work = workRow("tool", { startedAt: 100, completedAt: 700, status: "completed" }); + expect(latestPublicActivityAt([turnRow({ children: [work] })] as never)).toBe(700); + }); +}); + describe("latestPublicUpdate", () => { it("uses the newest assistant-authored conversation text", () => { const rows = [ diff --git a/tests/board-model.test.ts b/tests/board-model.test.ts index 906628b..b434f4e 100644 --- a/tests/board-model.test.ts +++ b/tests/board-model.test.ts @@ -112,6 +112,12 @@ describe("formatElapsed", () => { }); describe("cardElapsedMs", () => { + it("does not keep timing terminal cards without source completion timing", () => { + for (const status of ["failed", "interrupted", "completed", "skipped"] as const) { + const c = card(status); c.startedAt = 100; c.completedAt = null; + expect(cardElapsedMs(c, 30_000_000)).toBeNull(); + } + }); const now = 1_000_000; it("prefers the authoritative duration when the work finished", () => { const c = card("completed"); diff --git a/tests/dashboard-view.test.ts b/tests/dashboard-view.test.ts new file mode 100644 index 0000000..c4f577f --- /dev/null +++ b/tests/dashboard-view.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { activityFreshness, EMPTY_FILTERS, filterDashboard, filterOptions, matchesFilters, groupExecutions, completionSummary, reportedCompletion } from "../lib/dashboard-view"; +import { buildGlobalExecution } from "../lib/global-model"; +import { safeRepositoryLabel } from "../lib/collect"; +import { phaseCard as card, phaseExecution as execution, phaseSnapshot as snapshot } from "./vnext-fixtures"; + +describe("activity freshness without lifecycle inference", () => { + it("treats missing/invalid activity time as unknown", () => { + for (const at of [null, NaN, -1, 1_000_000]) expect(activityFreshness(card({ lastActivityAt: at }), 500).state).toBe("unknown"); + }); + it("uses observation time rather than updatedAt or fetch time", () => { + const e = execution("task", { updatedAt: 500_000, card: card({ activity: { at: 100, text: "Long build", kind: "tool" } }) }); + expect(activityFreshness(e.card, 130_000).state).toBe("quiet"); + expect(e.card.status).toBe("running"); + }); + it("returns to recent reporting on new activity and clamps small clock skew", () => { + expect(activityFreshness(card({ lastActivityAt: 1000 }), 1200).state).toBe("recent"); + expect(activityFreshness(card({ lastActivityAt: 1000 }), 900).ageMs).toBe(0); + }); + it("includes child and delegated worker observations in a logical execution", () => { + const e = buildGlobalExecution({ key: "x", source: "bb", title: "x", threadId: "x", projectId: null, updatedAt: 99_999, cards: [card({ lastActivityAt: 10 })], delegatedWorkers: [card({ source: "ollama-fleet", lastActivityAt: 900 })] }); + expect(e.card.lastActivityAt).toBe(900); + }); +}); +describe("bounded filtering and grouping", () => { + const a = execution("a"); + const b = execution("b", { projectId: "proj-b", projectName: "Project A", repository: null, card: card({ providerId: "grok", providerLabel: "Grok", status: "waiting" }) }); + it("combines filters by identity, even with duplicate project labels", () => { + expect(matchesFilters(a, { ...EMPTY_FILTERS, project: "value:proj-a", provider: "value:codex" })).toBe(true); + expect(matchesFilters(b, { ...EMPTY_FILTERS, project: "value:proj-a" })).toBe(false); + }); + it("supports missing project, repository and provider metadata", () => { + const unknown = execution("u", { projectId: null, projectName: null, repository: null, card: card({ providerId: null, providerLabel: null }) }); + expect(matchesFilters(unknown, { ...EMPTY_FILTERS, project: "unknown", repository: "unknown", provider: "unknown" })).toBe(true); + expect(filterOptions([unknown], "repository")).toEqual([{ value: "unknown", label: "Unknown repository" }]); + }); + it("filters active and recent while retaining snapshot counts and attention", () => { + const source = snapshot({ active: [a, b], recent: [a] }); + const result = filterDashboard(source, { ...EMPTY_FILTERS, provider: "value:grok", status: "value:waiting" }); + expect(result.active).toEqual([b]); expect(result.recent).toEqual([]); expect(source.active).toHaveLength(2); + }); + it("groups stably without merging same-name projects or altering order inside a group", () => { + const groups = groupExecutions([a, b, { ...a, key: "a2" }], "project"); + expect(groups).toHaveLength(2); expect(groups.find(g => g.key === "value:proj-a")!.executions.map(e => e.key)).toEqual(["a", "a2"]); + expect(groupExecutions([a, b], "none")[0]!.executions).toEqual([a, b]); + }); +}); +describe("completion provenance and repository privacy", () => { + it("omits absent completion metadata on legacy adapters and preserves their preview fallback", () => { + const e = buildGlobalExecution({ key: "legacy", source: "bb", title: "legacy", threadId: "legacy", projectId: null, updatedAt: 1, cards: [card({ outputPreview: "Tests: not run", status: "completed" })] }); + expect(Object.hasOwn(e.card, "reportedVerification")).toBe(false); + expect(completionSummary(e.card).reportedChecks).toEqual(["not run"]); + }); + it("bounds and redacts explicit completion fields without treating prose as checks", () => { + const fields = reportedCompletion("x".repeat(20_000) + "\n**Result:** password=synthetic-secret " + "r".repeat(300) + "\n" + "Tests: agent reported only\n".repeat(10)); + expect(fields.completionResult!.length).toBeLessThanOrEqual(240); + expect(fields.completionResult).not.toContain("synthetic-secret"); + expect(fields.reportedVerification).toHaveLength(4); + }); + it("uses bounded verification retained before source-output truncation", () => { + const result = completionSummary(card({ outputPreview: "A long review was truncated…", reportedVerification: ["not run (review only)"], completionResult: "Boundary review completed" })); + expect(result.reportedChecks).toEqual(["not run (review only)"]); expect(result.result).toBe("Boundary review completed"); + }); + it("preserves preview/failure and extracts only explicitly labeled reported checks", () => { + const text = "Result: new UI\nTests: 20 passed\nVerification: manual check pending"; + const result = completionSummary(card({ status: "failed", outputPreview: text, errorPreview: "Build failed" })); + expect(result.originalPreview).toBe(text); expect(result.failure).toBe("Build failed"); expect(result.reportedChecks).toEqual(["20 passed", "manual check pending"]); + }); + it("never infers verification from success wording", () => { + expect(completionSummary(card({ status: "completed", outputPreview: "Everything passed and is verified" })).reportedChecks).toEqual([]); + expect(completionSummary(card()).result).toContain("No result summary"); + }); + it("strips remote credentials, query strings and fragments", () => { + expect(safeRepositoryLabel("https://user:secret@github.com/example/a.git?token=secret#private")).toBe("github.com/example/a"); + expect(safeRepositoryLabel("git@github.com:example/a.git")).toBe("github.com/example/a"); + expect(safeRepositoryLabel("file:///private/repo")).toBeNull(); expect(safeRepositoryLabel(null)).toBeNull(); + }); +}); diff --git a/tests/global-app.test.tsx b/tests/global-app.test.tsx index 9ec960b..b92e9d0 100644 --- a/tests/global-app.test.tsx +++ b/tests/global-app.test.tsx @@ -60,7 +60,7 @@ function executionTimeline(overrides: Partial = {}): Executio function mountGlobal(options: Parameters[2] = {}): RenderedSlot { const registration = app.navPanels[0]!; - return renderSlot({ component: registration.component }, { subPath: "" } as never, options); + return renderSlot({ component: registration.component }, { subPath: "" } as never, { ...options, rpc: { release_announcement: () => ({ version: "0.7.0-dev.1", title: "Updates", highlights: [], dismissed: true }), ...options.rpc } }); } describe("global page registration", () => { @@ -118,7 +118,7 @@ describe("global operations rendering", () => { it("shows partial source failure without breaking available cards", async () => { const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ unavailableSources: ["redteam"], partial: true }) } }); expect(await slot.findByText("Auth refactor")).toBeTruthy(); - expect(slot.getByText(/Temporarily unavailable: Redteam/)).toBeTruthy(); + expect(slot.getByText(/Optional sources unavailable: Redteam/)).toBeTruthy(); expect(slot.getByText(/global view is bounded/i)).toBeTruthy(); slot.unmount(); }); @@ -239,11 +239,11 @@ describe("global refresh behavior", () => { it("refetches on a global invalidation but ignores a scoped-only signal", async () => { const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot() } }); await slot.findByText("Auth refactor"); - expect(slot.inspection.rpcCalls).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: ["thr_other"] }); - expect(slot.inspection.rpcCalls).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); - expect(slot.inspection.rpcCalls).toHaveLength(2); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(2); slot.unmount(); }); @@ -251,13 +251,13 @@ describe("global refresh behavior", () => { vi.useFakeTimers(); const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot() } }); await vi.runOnlyPendingTimersAsync(); - const initial = slot.inspection.rpcCalls.length; + const initial = slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").length; await vi.advanceTimersByTimeAsync(GLOBAL_DISCOVERY_INTERVAL_MS); - expect(slot.inspection.rpcCalls.length).toBeGreaterThan(initial); - const beforeUnmount = slot.inspection.rpcCalls.length; + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").length).toBeGreaterThan(initial); + const beforeUnmount = slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").length; slot.unmount(); await vi.advanceTimersByTimeAsync(GLOBAL_DISCOVERY_INTERVAL_MS * 2); - expect(slot.inspection.rpcCalls).toHaveLength(beforeUnmount); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(beforeUnmount); }); }); @@ -278,7 +278,7 @@ describe("terminal-state self-correction in the hook", () => { }, }); // The first fetch is in flight; a terminal signal arrives mid-flight. - expect(slot.inspection.rpcCalls).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); @@ -289,13 +289,13 @@ describe("terminal-state self-correction in the hook", () => { // The in-flight fetch settled, then exactly ONE trailing refetch ran — // the signal burst collapsed into a single retry, not a storm. expect(calls).toBe(2); - expect(slot.inspection.rpcCalls).toHaveLength(2); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(2); // Signals after settle go back to the normal immediate path. await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await new Promise((resolve) => setTimeout(resolve, 25)); expect(calls).toBe(3); - expect(slot.inspection.rpcCalls).toHaveLength(3); // no retry loop + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(3); // no retry loop slot.unmount(); }); }); @@ -348,7 +348,7 @@ describe("lazy shared execution timeline", () => { })); const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot(), execution_timeline: rpcTimeline } }); await slot.findByText("Auth refactor"); - expect(slot.inspection.rpcCalls).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); expect(rpcTimeline).not.toHaveBeenCalled(); const trigger = slot.getByRole("button", { name: "Open timeline for Auth refactor" }); @@ -357,7 +357,7 @@ describe("lazy shared execution timeline", () => { trigger.click(); expect(await slot.findByText("The collector remains bounded.")).toBeTruthy(); expect(rpcTimeline).toHaveBeenCalledTimes(1); - expect(slot.inspection.rpcCalls.filter((call) => call.method === "execution_timeline")).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").filter((call) => call.method === "execution_timeline")).toHaveLength(1); const rows = Array.from(document.querySelectorAll("[data-event-kind]")); expect(rows.map((row) => row.getAttribute("data-event-kind"))).toEqual(["execution-start", "model-selected", "tool-complete", "public-update", "execution-complete"]); expect(document.body.textContent).toContain("read_file · src/auth.ts · 1s"); @@ -480,7 +480,7 @@ describe("attention dismissal UI", () => { type: item.type, updatedAt: item.updatedAt, }); - expect(slot.inspection.rpcCalls.filter((call) => call.method === "global_dashboard")).toHaveLength(2); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").filter((call) => call.method === "global_dashboard")).toHaveLength(2); slot.unmount(); }); diff --git a/tests/global-collect.test.ts b/tests/global-collect.test.ts index 2abfc1f..ce99cde 100644 --- a/tests/global-collect.test.ts +++ b/tests/global-collect.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { BOARD_LIMITS } from "@/lib/board-model"; -import { BoardCollector, groupGlobalThreadRows } from "@/lib/collect"; +import { BoardCollector, groupGlobalThreadRows, PROJECT_METADATA_TIMEOUT_MS } from "@/lib/collect"; +afterEach(() => vi.useRealTimers()); function row(id: string, overrides: Record = {}) { return { @@ -34,9 +35,11 @@ function makeCollector(options: { redteam?: unknown; callRpcError?: Error; now?: () => number; + project?: () => Promise<{ name: string; gitRemoteUrl: string | null }>; } = {}) { const rows = options.rows ?? [row("thr_root", { status: "active", title: "Root work" })]; const sdk = { + projects: { get: vi.fn(options.project ?? (async () => { throw new Error("Project metadata unavailable"); })) }, threads: { list: vi.fn(async () => { if (options.listError) throw options.listError; @@ -59,6 +62,20 @@ function makeCollector(options: { } describe("global BB collection", () => { + it("enriches project metadata once per project and strips remote credentials", async () => { + const { collector, sdk } = makeCollector({ rows: [row("a", { status: "active" }), row("b", { status: "active" })], project: async () => ({ name: "Project", gitRemoteUrl: "https://user:secret@github.com/example/repo.git?token=private" }) }); + const result = await collector.globalDashboard(); + expect(result.active.map(e => e.repository)).toEqual(["github.com/example/repo", "github.com/example/repo"]); + expect(result.active[0]?.projectName).toBe("Project"); expect(sdk.projects.get).toHaveBeenCalledTimes(1); + }); + it("keeps native sessions when project metadata fails or hangs, without overlapping requests", async () => { + vi.useFakeTimers(); + const { collector, sdk } = makeCollector({ project: () => new Promise(() => {}) }); + const first = collector.globalDashboard(); await vi.advanceTimersByTimeAsync(PROJECT_METADATA_TIMEOUT_MS + 10); + expect((await first).active[0]).toMatchObject({ projectId: "proj", projectName: null, repository: null }); + const second = collector.globalDashboard({ fresh: true }); await vi.advanceTimersByTimeAsync(PROJECT_METADATA_TIMEOUT_MS + 10); await second; + expect(sdk.projects.get).toHaveBeenCalledTimes(1); + }); it("uses one bounded thread list and enriches one representative per logical execution", async () => { const rows = [ row("thr_root", { title: "Parent operation", status: "idle", updatedAt: 800 }), diff --git a/tests/public-sdk.test.ts b/tests/public-sdk.test.ts index 756833e..65742a7 100644 --- a/tests/public-sdk.test.ts +++ b/tests/public-sdk.test.ts @@ -7,8 +7,9 @@ import { experimental_scanPublicSdkOnly } from "@get-bb/plugin-sdk/testing"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; -const packageRoot = new URL("..", import.meta.url).pathname; +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); /** * Public packages beyond the SDK this plugin may import: @@ -28,6 +29,7 @@ const ALLOW = [ /^class-variance-authority$/u, /^@hugeicons\/(?:react|core-free-icons)$/u, /^vitest\/config$/u, + /^@testing-library\/react$/u, // Declared development-only DOM interaction helpers. ] as const; describe("public SDK only", () => { @@ -42,7 +44,7 @@ describe("public SDK only", () => { expect(scan.files.length).toBeGreaterThan(0); expect(scan.files.some((file) => file.endsWith("server.ts"))).toBe(true); expect(scan.files.some((file) => file.endsWith("app.tsx"))).toBe(true); - expect(scan.files.some((file) => file.endsWith("lib/bb-source.ts"))).toBe(true); + expect(scan.files.some((file) => file.replaceAll("\\", "/").endsWith("lib/bb-source.ts"))).toBe(true); }); it("still catches private packages and escaping paths", () => { @@ -79,4 +81,4 @@ describe("public SDK only", () => { }); rmSync(badRoot, { recursive: true, force: true }); }); -}); \ No newline at end of file +}); diff --git a/tests/releases.test.ts b/tests/releases.test.ts new file mode 100644 index 0000000..818c58f --- /dev/null +++ b/tests/releases.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { CURRENT_RELEASE, ReleaseAnnouncementStore } from "../lib/releases"; +import type { KvLike } from "../lib/dismissals"; +function storage(initial: unknown = undefined) { + const values = new Map([["release-dismissals", initial]]); + const kv: KvLike = { get: async (key: string) => values.get(key) as T | undefined, set: async (key, value) => { values.set(key, value); }, delete: async key => { values.delete(key); }, list: async () => [...values.keys()] }; + return { kv, values }; +} +describe("local versioned announcements", () => { + it("shows stable notes after 0.6.1 or an RC while preserving prior dismissals", async () => { + const previous = ["0.6.1", "0.6.2", "0.7.0-rc.1"]; + const { kv, values } = storage(previous); + expect(CURRENT_RELEASE.version).toBe("0.7.0"); + const store = new ReleaseAnnouncementStore(kv); + expect(await store.snapshot()).toMatchObject({ version: CURRENT_RELEASE.version, dismissed: false }); + await store.dismiss(CURRENT_RELEASE.version); + expect((await new ReleaseAnnouncementStore(kv).snapshot()).dismissed).toBe(true); + expect(values.get("release-dismissals")).toEqual([...previous, "0.7.0"]); + const rc = new ReleaseAnnouncementStore(kv, { ...CURRENT_RELEASE, version: "0.7.0-rc.1" }); + expect((await rc.snapshot()).dismissed).toBe(true); + }); + it.each([null, 42, "invalid", { dismissed: true }])("ignores non-array dismissal storage: %j", async value => { + const { kv } = storage(value); + expect((await new ReleaseAnnouncementStore(kv).snapshot()).dismissed).toBe(false); + }); + it("shows a new version and persists dismissal across reload", async () => { + const { kv } = storage(); const store = new ReleaseAnnouncementStore(kv); + expect((await store.snapshot()).dismissed).toBe(false); + await store.dismiss(CURRENT_RELEASE.version); + expect((await new ReleaseAnnouncementStore(kv).snapshot()).dismissed).toBe(true); + }); + it("a changed version appears, and downgrade remembers its own dismissal", async () => { + const { kv } = storage(); const old = new ReleaseAnnouncementStore(kv, { ...CURRENT_RELEASE, version: "0.6.1" }); + await old.dismiss("0.6.1"); + const next = new ReleaseAnnouncementStore(kv); + expect((await next.snapshot()).dismissed).toBe(false); await next.dismiss(CURRENT_RELEASE.version); + expect((await old.snapshot()).dismissed).toBe(true); expect((await next.snapshot()).dismissed).toBe(true); + }); + it("keeps notes available after dismissal and rejects stale-version writes", async () => { + const { kv } = storage(); const store = new ReleaseAnnouncementStore(kv); + await expect(store.dismiss("wrong-version")).rejects.toThrow("version changed"); + await store.dismiss(CURRENT_RELEASE.version); expect((await store.snapshot()).highlights.length).toBeGreaterThan(0); + }); + it("surfaces read and write errors without pretending dismissal succeeded", async () => { + const { kv } = storage(); kv.get = async () => { throw new Error("read failed"); }; + await expect(new ReleaseAnnouncementStore(kv).snapshot()).rejects.toThrow("read failed"); + const normal = storage(); normal.kv.set = async () => { throw new Error("write failed"); }; + const store = new ReleaseAnnouncementStore(normal.kv); await expect(store.dismiss(CURRENT_RELEASE.version)).rejects.toThrow("write failed"); expect((await store.snapshot()).dismissed).toBe(false); + }); + it("handles malformed storage and bounds repeated/concurrent dismissals", async () => { + const { kv, values } = storage([null, 42, "x".repeat(81), ...Array.from({ length: 40 }, (_, i) => `old-${i}`)]); + const store = new ReleaseAnnouncementStore(kv); await Promise.all([store.dismiss(CURRENT_RELEASE.version), store.dismiss(CURRENT_RELEASE.version)]); + const versions = values.get("release-dismissals") as string[]; expect(versions).toHaveLength(20); expect(versions.filter(v => v === CURRENT_RELEASE.version)).toHaveLength(1); + }); +}); diff --git a/tests/vnext-app.test.tsx b/tests/vnext-app.test.tsx new file mode 100644 index 0000000..26bb897 --- /dev/null +++ b/tests/vnext-app.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { act, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { loadPluginApp, renderSlot, type CapturedPluginApp } from "@get-bb/plugin-sdk/testing/app"; +import { phaseCard, phaseExecution, phaseSnapshot } from "./vnext-fixtures"; +import { CURRENT_RELEASE } from "../lib/releases"; +import { BOARD_CHANGED_CHANNEL } from "../contract/rpc"; +let app: CapturedPluginApp; +beforeEach(async () => { app = await loadPluginApp(() => import("../app")); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); +function mount(options: Parameters[2] = {}) { + return renderSlot({ component: app.navPanels[0]!.component }, { subPath: "" } as never, { ...options, rpc: { global_dashboard: () => phaseSnapshot(), release_announcement: () => ({ ...CURRENT_RELEASE, dismissed: true }), ...options.rpc } }); +} +describe("Phase 1 dashboard interactions", () => { + it("surfaces native collection failures without calling the source live", async () => { + const slot = mount({ rpc: { global_dashboard: () => phaseSnapshot({ active: [], unavailableSources: ["bb"] }) } }); + await slot.findByText("Native BB observation is unavailable. Refresh to retry; optional sources may still report."); + expect(slot.getByRole("alert")).toBeTruthy(); slot.unmount(); + }); + it("filters/group sessions and keeps actionable attention visible", async () => { + const attention = { id: "a", executionKey: "a", type: "failed" as const, status: "open" as const, title: "Urgent failure", message: "Review failure", firstObservedAt: 10, updatedAt: 20, threadId: "thread", source: "bb" as const }; + const b = phaseExecution("Task B", { projectId: "proj-b", projectName: "Project B", card: phaseCard({ title: "Task B", providerId: "grok", providerLabel: "Grok" }) }); + const slot = mount({ rpc: { global_dashboard: () => phaseSnapshot({ active: [phaseExecution("Task A"), b], attention: [attention] }) } }); + await slot.findByText("Task B"); + fireEvent.change(slot.getByLabelText("Filter by project"), { target: { value: "value:proj-a" } }); + expect(slot.queryByText("Task B")).toBeNull(); expect(slot.getByText("Task A")).toBeTruthy(); expect(slot.getByText("Urgent failure")).toBeTruthy(); + fireEvent.change(slot.getByLabelText("Group sessions by"), { target: { value: "project" } }); + expect(slot.getByText("Project A (1)")).toBeTruthy(); + fireEvent.click(slot.getByText("Reset filters")); expect(slot.getByText("Task B")).toBeTruthy(); slot.unmount(); + }); + it("quiet reporting retains running state and the existing animated strip", async () => { + const e = phaseExecution("Long build", { card: phaseCard({ title: "Long build", activity: { text: "Building", kind: "command", at: Date.now() - 600_000 } }) }); + const slot = mount({ rpc: { global_dashboard: () => phaseSnapshot({ active: [e] }) } }); + await slot.findByText("Long build"); expect(slot.container.querySelector('[data-freshness="quiet"]')?.textContent).toContain("task may still be running"); + expect(slot.getByTestId("kanban-live-strip")).toBeTruthy(); expect(slot.container.querySelector('[data-global-card="active"]')?.getAttribute("data-status")).toBe("running"); slot.unmount(); + }); + it("collapses empty attention and expands on actionable change", async () => { + let result = phaseSnapshot(); const slot = mount({ rpc: { global_dashboard: () => result } }); + await slot.findByText("Task A"); expect(slot.container.querySelector("[data-empty-attention]")?.className).not.toContain("py-4"); + result = phaseSnapshot({ attention: [{ id: "a", executionKey: "a", type: "action-required", status: "open", title: "Approval", message: "Approve tool", firstObservedAt: 10, updatedAt: 20, threadId: "thread", source: "bb" }] }); + await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await slot.findByText("Approve tool"); expect(slot.container.querySelector("[data-empty-attention]")).toBeNull(); slot.unmount(); + }); + it("shows watcher errors independently of transport and recovers on reconnect", async () => { + const fetch = vi.fn(() => phaseSnapshot({ monitoring: { status: "degraded", startedAt: 1, lastEventAt: 2, lastPublishedAt: null, lastErrorAt: 3 } })); + const slot = mount({ rpc: { global_dashboard: fetch } }); + await slot.findByText("Task A"); expect(slot.getByText("Connected")).toBeTruthy(); expect(slot.getByRole("alert").textContent).toContain("watcher has a problem"); + await slot.behavior.setRealtimeConnectionState("reconnecting"); expect(slot.getByText("Connection: reconnecting")).toBeTruthy(); + expect(slot.getByLabelText("Monitoring health").textContent).toContain("Watcher health unconfirmed"); + const calls = fetch.mock.calls.length; await slot.behavior.setRealtimeConnectionState("connected"); await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThan(calls)); slot.unmount(); + }); + it("qualifies cached watching health after a failed refresh even while connected", async () => { + let fail = false; + const slot = mount({ rpc: { global_dashboard: () => { + if (fail) throw new Error("Synthetic refresh failure"); + return phaseSnapshot({ monitoring: { status: "watching", startedAt: 1, lastEventAt: null, lastPublishedAt: null, lastErrorAt: null } }); + } } }); + await slot.findByText("Task A"); + expect(slot.getByLabelText("Monitoring health").textContent).toContain("Watcher: watching"); + await waitFor(() => expect((slot.getByText("Refresh") as HTMLButtonElement).disabled).toBe(false)); + fail = true; fireEvent.click(slot.getByText("Refresh")); + await slot.findByText(/Synthetic refresh failure/); + expect(slot.getByText("Connected")).toBeTruthy(); + expect(slot.getByLabelText("Monitoring health").textContent).toContain("Watcher health unconfirmed"); + slot.unmount(); + }); + it("qualifies an overdue cached snapshot while a refresh remains pending", async () => { + vi.useFakeTimers(); + let calls = 0; + const slot = mount({ rpc: { global_dashboard: () => ++calls === 1 + ? phaseSnapshot({ monitoring: { status: "watching", startedAt: 1, lastEventAt: null, lastPublishedAt: null, lastErrorAt: null } }) + : new Promise(() => {}) } }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(slot.getByLabelText("Monitoring health").textContent).toContain("Watcher: watching"); + await act(async () => { await vi.advanceTimersByTimeAsync(61_000); }); + expect(slot.getByLabelText("Monitoring health").textContent).toContain("Refresh is overdue"); + expect(slot.getByLabelText("Monitoring health").textContent).toContain("Watcher health unconfirmed"); + slot.unmount(); + }); + it("labels reported verification and retains source-output access", async () => { + const done = phaseExecution("Done", { card: phaseCard({ title: "Done", status: "completed", column: "output", outputPreview: "Result: UI updated\nTests: 15 passed" }) }); + const slot = mount({ rpc: { global_dashboard: () => phaseSnapshot({ active: [], recent: [done] }) } }); + await slot.findByText("Done"); expect(slot.getByText("Reported by the agent; not independently verified.")).toBeTruthy(); expect(slot.getByText("Original output preview")).toBeTruthy(); expect(slot.getByRole("button", { name: "Open Done" })).toBeTruthy(); slot.unmount(); + }); +}); +describe("release announcements in the dashboard", () => { + it("dismisses and allows later viewing of the same notes", async () => { + const dismiss = vi.fn(async () => ({ dismissed: true })); + const slot = mount({ rpc: { release_announcement: () => ({ ...CURRENT_RELEASE, dismissed: false }), dismiss_release_announcement: dismiss } }); + await slot.findByRole("region", { name: "What's New" }); fireEvent.click(slot.getByText("Dismiss announcement")); + await waitFor(() => expect(slot.queryByRole("region", { name: "What's New" })).toBeNull()); expect(dismiss).toHaveBeenCalledWith({ version: CURRENT_RELEASE.version }); + fireEvent.click(slot.getByText("Release notes")); expect(slot.getByRole("region", { name: "What's New" })).toBeTruthy(); slot.unmount(); + }); + it("keeps the announcement visible when persistence fails", async () => { + const slot = mount({ rpc: { release_announcement: () => ({ ...CURRENT_RELEASE, dismissed: false }), dismiss_release_announcement: () => { throw new Error("storage unavailable"); } } }); + await slot.findByText("Dismiss announcement"); fireEvent.click(slot.getByText("Dismiss announcement")); await slot.findByText("Dismissal could not be saved. Please try again."); expect(slot.getByRole("region", { name: "What's New" })).toBeTruthy(); slot.unmount(); + }); +}); diff --git a/tests/vnext-fixtures.ts b/tests/vnext-fixtures.ts new file mode 100644 index 0000000..1083d48 --- /dev/null +++ b/tests/vnext-fixtures.ts @@ -0,0 +1,13 @@ +import type { BoardCard, GlobalExecution, GlobalDashboardSnapshot } from "../contract/rpc"; +export function phaseCard(overrides: Partial = {}): BoardCard { + return { key: "card", parentKey: null, depth: 0, kind: "thread", source: "bb", title: "Synthetic task", subtitle: null, + status: "running", column: "active", threadId: "thread", providerId: "codex", providerLabel: "Codex", model: null, + startedAt: 100, completedAt: null, durationMs: null, phaseTitle: null, promptPreview: null, activity: null, + outputPreview: null, errorPreview: null, tokens: null, toolCalls: null, waitingOn: null, attention: null, planStatus: null, isRoot: false, ...overrides }; +} +export function phaseExecution(key: string, overrides: Partial = {}): GlobalExecution { + return { key, card: phaseCard({ key: `card:${key}`, title: key }), projectId: "proj-a", projectName: "Project A", repository: "github.com/example/a", childCount: 0, activeChildCount: 0, updatedAt: 1000, ...overrides }; +} +export function phaseSnapshot(overrides: Partial = {}): GlobalDashboardSnapshot { + return { active: [phaseExecution("Task A")], recent: [], attention: [], counts: { active: 1, waiting: 0, attention: 0, recent: 0, failures: 0 }, unavailableSources: [], dismissedCount: 0, partial: false, fetchedAt: 1000, ...overrides }; +} diff --git a/tests/vnext-server.test.ts b/tests/vnext-server.test.ts new file mode 100644 index 0000000..6c27603 --- /dev/null +++ b/tests/vnext-server.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import plugin from "../server"; +import { CURRENT_RELEASE } from "../lib/releases"; +import type { GlobalDashboardSnapshot } from "../contract/rpc"; +describe("Phase 1 server contracts", () => { + it("persists a schema-validated release dismissal across plugin reload", async () => { + const host = createFakePluginHost({ pluginId: "agent-board" }); await plugin(host.bb); + const before = await host.harness.behavior.callRpc("release_announcement", {}); + expect(before).toMatchObject({ version: CURRENT_RELEASE.version, dismissed: false }); + await host.harness.behavior.callRpc("dismiss_release_announcement", { version: CURRENT_RELEASE.version }); + const next = await host.harness.lifecycle.reload(plugin); + expect(await next.harness.behavior.callRpc("release_announcement", {})).toMatchObject({ dismissed: true, version: CURRENT_RELEASE.version }); await next.harness.lifecycle.dispose(); + }); + it("survives failed watcher attachment and exposes unavailable health", async () => { + const host = createFakePluginHost({ pluginId: "agent-board", sdk: { subscribe: () => { throw new Error("Synthetic subscription failure"); } } }); + await plugin(host.bb); const dashboard = await host.harness.behavior.callRpc("global_dashboard", {}) as GlobalDashboardSnapshot; + expect(dashboard.monitoring?.status).toBe("unavailable"); expect(JSON.stringify(dashboard.monitoring)).not.toContain("Synthetic"); await host.harness.lifecycle.dispose(); + }); +}); diff --git a/tests/watch.test.ts b/tests/watch.test.ts index 4cab78a..7a9e9fd 100644 --- a/tests/watch.test.ts +++ b/tests/watch.test.ts @@ -44,6 +44,25 @@ beforeEach(() => { vi.useFakeTimers(); }); +describe("watcher health", () => { + it("does not mark quiet sources as disconnected", () => { + const { watcher } = makeWatcher({ now: () => 900_000 }); + expect(watcher.health()).toMatchObject({ status: "watching", lastEventAt: null, lastPublishedAt: null }); watcher.dispose(); + }); + it("records publication errors and recovers after a successful signal", async () => { + let fail = true; let now = 10; + const watcher = new BoardWatcher({ publish: () => { if (fail) throw new Error("private source detail"); }, getParent: async () => null, now: () => now }); + watcher.touchGlobal(); await watcher.handleChanged(changed("thread")); expect(() => watcher.flush()).not.toThrow(); + expect(watcher.health()).toMatchObject({ status: "degraded", lastErrorAt: 10 }); + fail = false; now = 20; await watcher.handleChanged(changed("thread")); watcher.flush(); + expect(watcher.health()).toMatchObject({ status: "watching", lastEventAt: 20, lastPublishedAt: 20 }); expect(JSON.stringify(watcher.health())).not.toContain("private"); watcher.dispose(); + }); + it("disposal stops publication and marks the watcher stopped", async () => { + const { watcher, recorded } = makeWatcher(); watcher.dispose(); await watcher.handleChanged(changed("x")); watcher.flush(); + expect(watcher.health().status).toBe("disposed"); expect(recorded.publishes).toEqual([]); + }); +}); + afterEach(() => { vi.useRealTimers(); }); diff --git a/tsconfig.json b/tsconfig.json index 819a5be..e3cf06c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "resolveJsonModule": true, "jsx": "react-jsx", "lib": [ "ES2022", diff --git a/vitest.config.ts b/vitest.config.ts index 5fd4cd0..d5f81fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,8 +11,14 @@ export default defineConfig({ }, }, test: { - maxWorkers: 4, // Keep browser harness startup within bounds on small hosts. + pool: "threads", + maxWorkers: 1, // Serial startup is reliable on the Windows development host. + hookTimeout: 20_000, // Cold app imports include the existing large icon registry. + deps: { + // Compile the large data-only icon barrel once; keep real icons and hook limits. + optimizer: { ssr: { enabled: true, include: ["@hugeicons/core-free-icons"] } }, + }, include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], environment: "node", }, -}); \ No newline at end of file +});