From 2a651afffe32877ce3544aaba451353618ec16a7 Mon Sep 17 00:00:00 2001 From: Alex Barclay Date: Sat, 5 Sep 2026 17:21:08 -0600 Subject: [PATCH] feat(dashboard): wire the Intake transport so the feedback adapter can read PR #65 landed adapters/feedback.ts and its 12 tests, but no Tauri command existed to feed it - lib.rs registered halyard_status, halyard_queue, audience_health, audience_posts and scan_local_projects, and nothing else. The adapter has had nothing to read at runtime since it merged. This was blocked on telltale's base URL, which is now recorded. Six touch points, following the audience_posts path exactly: dashboard.rs feedback_issues - GET {TELLTALE_BASE_URL}/v1/issues with a bearer TELLTALE_TOKEN, returning the envelope unchanged lib.rs register it in generate_handler!, without which the command does not exist at runtime api.ts tauriFeedbackReader implementing the FeedbackReader seam store.ts pollFeedback, mirroring pollAudience Dashboard.svelte prop, poll call, and the SOURCE_LABEL entry - without the last, the badge renders raw lowercase "feedback" through the ?? c.source fallback App.svelte inject the real reader TELLTALE_BASE_URL has no default, deliberately. Unset means "Intake is not configured on this machine", which the adapter must tell apart from "configured but unreachable" - the second greys a lane, the first should not invent one. A non-2xx does not echo the response body: a 401 here is an operator-token problem and the body is not worth risking in a log. The command passes the whole { issues, errors } envelope through rather than reshaping, so there is exactly one place the payload is understood, and that place is pinned on both sides by the wire contract from PR #66. Three store tests added, since every sibling poll has them and this one would otherwise have shipped without. Each was driven red on the defect it exists to catch: pollFeedback drops its result -> 2 red (composition, error passthrough) pollFeedback evicts the wrong source -> 1 red (replace semantics) Suite 159 -> 162, typecheck 357 files 0 errors, cargo check clean. Unrelated, found en route: the Rust build cache held absolute paths under D:\MajorProjects\CURRENT\, the pre-reorg location, and failed to build until target/debug/build was cleared. Third instance of reorg fallout after the audience venv; nothing in this diff caused or fixes it. Co-Authored-By: Claude Opus 5 (1M context) --- cockpit/ui/src-tauri/src/dashboard.rs | 46 +++++++++++++++++ cockpit/ui/src-tauri/src/lib.rs | 1 + cockpit/ui/src/App.svelte | 3 +- cockpit/ui/src/lib/dashboard/api.ts | 8 +++ cockpit/ui/src/lib/dashboard/store.test.ts | 60 ++++++++++++++++++++++ cockpit/ui/src/lib/dashboard/store.ts | 11 ++++ cockpit/ui/src/views/Dashboard.svelte | 6 +++ 7 files changed, 134 insertions(+), 1 deletion(-) diff --git a/cockpit/ui/src-tauri/src/dashboard.rs b/cockpit/ui/src-tauri/src/dashboard.rs index df92984..4afdf81 100644 --- a/cockpit/ui/src-tauri/src/dashboard.rs +++ b/cockpit/ui/src-tauri/src/dashboard.rs @@ -13,6 +13,11 @@ //! - HALYARD_CONFIG_DIR (default ".") — CWD for the spawn; Halyard resolves config //! relative to CWD. //! - AUDIENCE_API_URL (default "http://localhost:8080"). +//! - TELLTALE_BASE_URL — the Worker origin, e.g. +//! "https://telltale.openbarclay.workers.dev". No default: unset means the +//! Intake lane is simply not configured, which is different from unreachable. +//! - TELLTALE_TOKEN — the operator read token (`OPERATOR_READ_TOKEN` on the +//! Worker). The desktop host holds it so it never reaches the webview. use serde_json::Value; use std::process::Command; @@ -99,6 +104,47 @@ pub async fn audience_posts() -> Result { Ok(unwrap_posts(body)) } +fn telltale_base() -> Option { + // No default. An unset base URL means "Intake is not configured on this + // machine", which the adapter must be able to tell apart from "configured but + // unreachable" — the second greys a lane, the first should not invent one. + std::env::var("TELLTALE_BASE_URL").ok().filter(|s| !s.trim().is_empty()) +} + +/// `GET {TELLTALE_BASE_URL}/v1/issues` → `{ issues, errors }` (spec §6.3), passed +/// through to the feedback adapter unchanged. +/// +/// The shape is pinned on both sides by the wire contract in +/// `adapters/contracts/telltale-issues.contract.json`; this command deliberately +/// does no reshaping, so there is exactly one place the payload is understood. +/// +/// §6.3's per-repo `errors` array is why this returns the whole envelope rather +/// than just the issues: one bad repo must not blank the entire feedback lane. +#[tauri::command] +pub async fn feedback_issues() -> Result { + let base = telltale_base().ok_or_else(|| "TELLTALE_BASE_URL is not set".to_string())?; + let token = std::env::var("TELLTALE_TOKEN") + .map_err(|_| "TELLTALE_TOKEN is not set".to_string())?; + + let url = format!("{}/v1/issues", base.trim_end_matches('/')); + let resp = reqwest::Client::new() + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("telltale /v1/issues request failed: {e}"))?; + + if !resp.status().is_success() { + // Deliberately does not echo the body: a 401 from this endpoint is an + // operator-token problem, and the body is not worth risking in a log. + return Err(format!("telltale /v1/issues returned {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("telltale /v1/issues body was not JSON: {e}")) +} + /// Normalize the `/posts` body to a JSON array — tolerate a bare array or a common /// `{ posts | data | items: [...] }` envelope so the adapter always sees a list. fn unwrap_posts(body: Value) -> Value { diff --git a/cockpit/ui/src-tauri/src/lib.rs b/cockpit/ui/src-tauri/src/lib.rs index 11cecdb..aa0a469 100644 --- a/cockpit/ui/src-tauri/src/lib.rs +++ b/cockpit/ui/src-tauri/src/lib.rs @@ -66,6 +66,7 @@ pub fn run() { dashboard::halyard_queue, dashboard::audience_health, dashboard::audience_posts, + dashboard::feedback_issues, local_projects::scan_local_projects, ]) .setup(|app| { diff --git a/cockpit/ui/src/App.svelte b/cockpit/ui/src/App.svelte index e8922d1..c1d884c 100644 --- a/cockpit/ui/src/App.svelte +++ b/cockpit/ui/src/App.svelte @@ -9,7 +9,7 @@ import Switcher, { type ViewEntry } from './lib/Switcher.svelte'; import Dashboard from './views/Dashboard.svelte'; import ApprovalOverlay, { type ApprovalRequest } from './lib/ApprovalOverlay.svelte'; - import { tauriHalyardReader, tauriAudienceReader } from './lib/dashboard/api'; + import { tauriHalyardReader, tauriAudienceReader, tauriFeedbackReader } from './lib/dashboard/api'; import type { LocalReader, LocalProjectDoc } from './lib/dashboard/adapters/local'; // PLUGIN RUNTIME (Lane S): the sandboxed view-plugin bridge (Lane V) + app-plugin // discovery (Lane A). The one topbar switcher unifies host views + both plugin kinds. @@ -379,6 +379,7 @@ fleet.onPhase(cb)} diff --git a/cockpit/ui/src/lib/dashboard/api.ts b/cockpit/ui/src/lib/dashboard/api.ts index 66f4308..71d0e9f 100644 --- a/cockpit/ui/src/lib/dashboard/api.ts +++ b/cockpit/ui/src/lib/dashboard/api.ts @@ -9,6 +9,7 @@ import { invoke } from '@tauri-apps/api/core'; import type { HalyardReader, HalyardReleaseStatus, HalyardProposal } from './adapters/halyard'; import type { AudienceReader, AudiencePost } from './adapters/audience'; +import type { FeedbackReader, FeedbackIssuesResponse } from './adapters/feedback'; /** Reads Halyard by spawning the `halyard` CLI (Rust side parses stdout JSON). */ export const tauriHalyardReader: HalyardReader = { @@ -21,3 +22,10 @@ export const tauriAudienceReader: AudienceReader = { health: () => invoke('audience_health'), posts: () => invoke('audience_posts'), }; + +/** §6.3 — the whole `{ issues, errors }` envelope, not just the array. A repo that + * failed to answer must reach the adapter; dropping `errors` would let one bad + * repo silently blank the lane instead of flagging it. */ +export const tauriFeedbackReader: FeedbackReader = { + issues: () => invoke('feedback_issues'), +}; diff --git a/cockpit/ui/src/lib/dashboard/store.test.ts b/cockpit/ui/src/lib/dashboard/store.test.ts index 09ce1f6..a2044ad 100644 --- a/cockpit/ui/src/lib/dashboard/store.test.ts +++ b/cockpit/ui/src/lib/dashboard/store.test.ts @@ -3,6 +3,7 @@ import { newBoard, pollHalyard, pollAudience, + pollFeedback, pollLocal, seedFleet, applyFleetPhase, @@ -15,6 +16,7 @@ import { } from './store'; import type { HalyardReader } from './adapters/halyard'; import type { AudienceReader } from './adapters/audience'; +import type { FeedbackReader, TelltaleIssue } from './adapters/feedback'; import type { LocalReader } from './adapters/local'; import type { Snapshot } from '../types'; @@ -29,6 +31,18 @@ const audienceReader = (alive: boolean, posts: any[] = []): AudienceReader => ({ health: async () => alive, posts: async () => posts, }); +const feedbackIssue = (over: Partial = {}): TelltaleIssue => ({ + repo: 'adbarc92/hexy', number: 1, title: 'crash on launch', body: '', + kind: 'crash', project: 'hexy', isOpen: true, hasAssignee: false, + createdIso: '2026-06-08T12:00:00Z', updatedIso: '2026-06-08T12:00:00Z', + labels: ['telltale', 'telltale:crash'], + url: 'https://github.com/adbarc92/hexy/issues/1', + ...over, +}); +const feedbackReader = ( + issues: TelltaleIssue[] = [], + errors: Array<{ project: string; message: string }> = [], +): FeedbackReader => ({ issues: async () => ({ issues, errors }) }); describe('board composition', () => { it('composes cards from multiple sources, keyed by projectId', async () => { @@ -178,3 +192,49 @@ function fleetCardStub(id: string, stage: any) { updatedIso: '2026-06-09T12:00:00Z', staleAfterSec: 120, health: 'ok' as const, }; } + +// ── Intake lane (§6) ──────────────────────────────────────────────────────── +// The transport for this source landed after the adapter did, so these pin the +// store seam specifically: that `pollFeedback` writes under the 'feedback' +// source and that one bad repo cannot take the lane down with it. +describe('feedback poll', () => { + it('writes cards under the feedback source', async () => { + let board = newBoard(); + board = await pollFeedback(board, feedbackReader([feedbackIssue()]), {}, NOW); + + const cards = cardList(board).filter((c) => c.source === 'feedback'); + expect(cards.length).toBe(1); + expect(cards[0].projectId).toBe('feedback:hexy'); + }); + + it('replaces only the feedback lane, leaving other sources standing', async () => { + let board = newBoard(); + board = await pollHalyard( + board, + halyardReader([{ release_id: 'r1', app: 'aurora', surface: 'web', version: '4.2', state: 'live', flag: null, waiting_on: '' }]), + {}, + NOW, + ); + board = await pollFeedback(board, feedbackReader([feedbackIssue()]), {}, NOW); + // A second poll returning nothing must clear feedback and touch nothing else. + board = await pollFeedback(board, feedbackReader([]), {}, NOW); + + expect(cardList(board).some((c) => c.source === 'halyard')).toBe(true); + expect(cardList(board).filter((c) => c.source === 'feedback').length).toBe(0); + }); + + it('§6.3 — a project that failed to answer does not blank the lane', async () => { + let board = newBoard(); + board = await pollFeedback( + board, + feedbackReader([feedbackIssue({ project: 'hexy' })], [{ project: 'lineage', message: 'config_error: no token' }]), + {}, + NOW, + ); + + // The healthy project still renders; the failure is surfaced, not swallowed. + const cards = cardList(board).filter((c) => c.source === 'feedback'); + expect(cards.some((c) => c.projectId === 'feedback:hexy')).toBe(true); + expect(JSON.stringify(cards)).toContain('lineage'); + }); +}); diff --git a/cockpit/ui/src/lib/dashboard/store.ts b/cockpit/ui/src/lib/dashboard/store.ts index 190ca87..a801ccf 100644 --- a/cockpit/ui/src/lib/dashboard/store.ts +++ b/cockpit/ui/src/lib/dashboard/store.ts @@ -11,6 +11,7 @@ import { STAGE_ORDINAL, isPipelineStage } from './model'; import type { Phase, Snapshot } from '../types'; import { halyardCards, type HalyardReader } from './adapters/halyard'; import { audienceCards, type AudienceReader } from './adapters/audience'; +import { feedbackCards, type FeedbackReader } from './adapters/feedback'; import { localCards, type LocalReader } from './adapters/local'; import { fleetCard, fleetCardsFromSnapshots, type FleetUnitState } from './adapters/fleet'; import { appPluginCard, type PluginUnit, type PluginState } from './adapters/appPlugin'; @@ -76,6 +77,16 @@ export async function pollAudience( return replaceSource(state, 'audience', cards); } +export async function pollFeedback( + state: BoardState, + reader: FeedbackReader, + overrides: Record = {}, + now: () => Date = () => new Date(), +): Promise { + const cards = await feedbackCards(reader, { overrides, now }); + return replaceSource(state, 'feedback', cards); +} + export async function pollLocal( state: BoardState, reader: LocalReader, diff --git a/cockpit/ui/src/views/Dashboard.svelte b/cockpit/ui/src/views/Dashboard.svelte index 9f8e4ff..162c91b 100644 --- a/cockpit/ui/src/views/Dashboard.svelte +++ b/cockpit/ui/src/views/Dashboard.svelte @@ -9,6 +9,7 @@ newBoard, pollHalyard, pollAudience, + pollFeedback, pollLocal, seedFleet, applyFleetPhase, @@ -21,6 +22,7 @@ import type { ProjectCard, Stage } from '../lib/dashboard/model'; import type { HalyardReader } from '../lib/dashboard/adapters/halyard'; import type { AudienceReader } from '../lib/dashboard/adapters/audience'; + import type { FeedbackReader } from '../lib/dashboard/adapters/feedback'; import type { LocalReader } from '../lib/dashboard/adapters/local'; import type { Snapshot, Phase } from '../lib/types'; import type { PluginUnit } from '../lib/dashboard/adapters/appPlugin'; @@ -30,6 +32,7 @@ interface Props { halyardReader?: HalyardReader; audienceReader?: AudienceReader; + feedbackReader?: FeedbackReader; localReader?: LocalReader; fleetSnapshots?: Snapshot[]; /** Subscribe to fleet `phase_changed`; returns an unsubscribe. */ @@ -44,6 +47,7 @@ let { halyardReader, audienceReader, + feedbackReader, localReader, fleetSnapshots = [], onFleetPhase, @@ -72,6 +76,7 @@ try { if (halyardReader) board = await pollHalyard(board, halyardReader, {}, () => new Date()); if (audienceReader) board = await pollAudience(board, audienceReader, {}, () => new Date()); + if (feedbackReader) board = await pollFeedback(board, feedbackReader, {}, () => new Date()); if (localReader) board = await pollLocal(board, localReader); } finally { pulling = false; @@ -129,6 +134,7 @@ 'app-plugin': 'APP', manual: 'MANUAL', local: 'LOCAL', + feedback: 'FEEDBACK', }; // §8 #2: actions deep-link OUT. Tauri's opener handles custom + http schemes; in a