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