diff --git a/cockpit/ui/src/lib/dashboard/adapters/adapters.test.ts b/cockpit/ui/src/lib/dashboard/adapters/adapters.test.ts index b2065d4..9641081 100644 --- a/cockpit/ui/src/lib/dashboard/adapters/adapters.test.ts +++ b/cockpit/ui/src/lib/dashboard/adapters/adapters.test.ts @@ -85,12 +85,21 @@ describe('audience adapter', () => { return { health: async () => alive, posts: async () => posts }; } - it('maps post status onto canonical stages', async () => { + // Every status below is one Audience actually emits, taken from its generated + // PostStatusSchema and pinned in audience.contract.test.ts. The previous version of + // this block asserted on 'published', 'rejected' and 'approval-pending' — three + // strings Audience has never emitted — so it was green against an invention. + it('maps every real post status onto a canonical stage', async () => { const cases: Array<[string, string]> = [ ['draft', 'Spec'], ['generating', 'Build'], - ['published', 'Live'], - ['rejected', 'Archived'], + ['ready_for_review', 'Blocked'], + ['awaiting_approval', 'Blocked'], + ['approved', 'Build'], + ['publishing', 'Build'], + ['fully_published', 'Live'], + ['partially_published', 'Blocked'], + ['failed', 'Failed'], ]; for (const [status, expected] of cases) { const cards = await audienceCards(reader(true, [{ id: '1', status }]), { now: NOW }); @@ -98,12 +107,21 @@ describe('audience adapter', () => { } }); - it('approval-pending → Blocked (approval gate)', async () => { - const cards = await audienceCards(reader(true, [{ id: '1', status: 'approval-pending', platforms: ['x'] }]), { now: NOW }); + it('awaiting_approval → Blocked (the approve-before-post gate)', async () => { + // The defect this replaces: Audience emits `awaiting_approval`, the adapter + // looked for `approval-pending`, so the one gate that must stop for a human + // fell through to `default` and never fired. + const cards = await audienceCards(reader(true, [{ id: '1', status: 'awaiting_approval' }]), { now: NOW }); expect(cards[0].stage).toBe('Blocked'); expect(cards[0].blocked?.gate).toBe('approval'); }); + it('a status absent from the contract classifies as nothing, and does not throw', async () => { + const cards = await audienceCards(reader(true, [{ id: '1', status: 'a_status_from_the_future' }]), { now: NOW }); + expect(cards[0].stage).not.toBe('Blocked'); + expect(cards[0].stage).not.toBe('Live'); + }); + it('failed → Failed', async () => { const cards = await audienceCards(reader(true, [{ id: '1', status: 'failed' }]), { now: NOW }); expect(cards[0].stage).toBe('Failed'); @@ -117,7 +135,7 @@ describe('audience adapter', () => { }); it('rolls up past the threshold into one card', async () => { - const posts = Array.from({ length: 12 }, (_, i) => ({ id: `${i}`, status: i < 3 ? 'approval-pending' : 'published' })); + const posts = Array.from({ length: 12 }, (_, i) => ({ id: `${i}`, status: i < 3 ? 'awaiting_approval' : 'fully_published' })); const cards = await audienceCards(reader(true, posts), { now: NOW, rollupThreshold: 8 }); expect(cards).toHaveLength(1); expect(cards[0].stage).toBe('Blocked'); diff --git a/cockpit/ui/src/lib/dashboard/adapters/audience.contract.test.ts b/cockpit/ui/src/lib/dashboard/adapters/audience.contract.test.ts new file mode 100644 index 0000000..795ed08 --- /dev/null +++ b/cockpit/ui/src/lib/dashboard/adapters/audience.contract.test.ts @@ -0,0 +1,55 @@ +// Wire contract — Audience `GET /posts` status vocabulary, consumer side. +// +// Audience generates this enum from its schema (`packages/contracts/src/generated/ +// enums.ts`, `PostStatusSchema`), so unlike the telltale seam the producer side is +// already authoritative and machine-generated. This file's job is to stop THIS repo +// drifting from it silently — which is exactly what happened: the §6.2 spec listed a +// vocabulary taken from a written digest, three of its six strings were fictional, +// and `awaiting_approval` — the approve-before-post gate — classified as nothing. + +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import raw from './contracts/audience-post-status.contract.json?raw'; +import type { AudiencePostStatus } from './audience'; + +// Bump only after re-reading Audience's generated enum and updating the mapping. +const CONTRACT_SHA256 = 'f1f7f57231511a9fcfd0943bee041115df5065392a7ff85a25ce6d44952e7c64'; + +const contract = JSON.parse(raw) as { statuses: string[] }; + +const canonicalSha = (text: string) => + createHash('sha256').update(JSON.stringify(JSON.parse(text))).digest('hex'); + +// Compile-time exhaustiveness: tsc fails here if AudiencePostStatus gains or loses a +// member. The runtime assertion below ties this literal to the contract file, so the +// union, the contract and Audience's own enum cannot drift apart in any direction. +const DECLARED: Record = { + draft: true, + generating: true, + ready_for_review: true, + awaiting_approval: true, + approved: true, + publishing: true, + fully_published: true, + partially_published: true, + failed: true, +}; + +describe('audience post-status contract', () => { + it('the contract has not moved under this repo', () => { + expect(canonicalSha(raw)).toBe(CONTRACT_SHA256); + }); + + it('the declared union is exactly the contract vocabulary', () => { + expect(Object.keys(DECLARED).sort()).toEqual([...contract.statuses].sort()); + }); + + it('carries the three strings the old spec invented, as a regression guard', () => { + // `approval-pending`, `published` and `rejected` were in the §6.2 table and in + // this repo's tests. Audience emits none of them. If one ever reappears in the + // contract, something has been copied from prose again rather than the schema. + for (const fiction of ['approval-pending', 'published', 'rejected']) { + expect(contract.statuses).not.toContain(fiction); + } + }); +}); diff --git a/cockpit/ui/src/lib/dashboard/adapters/audience.ts b/cockpit/ui/src/lib/dashboard/adapters/audience.ts index 6633ba1..37bc1b2 100644 --- a/cockpit/ui/src/lib/dashboard/adapters/audience.ts +++ b/cockpit/ui/src/lib/dashboard/adapters/audience.ts @@ -12,11 +12,37 @@ import { resolveStage, applyOverride } from '../stage'; export const AUDIENCE_SOURCE: Source = 'audience'; /** One element of `GET /posts` (the fields the dashboard reads). */ +/** + * §6.2's status vocabulary, taken from Audience's own generated contract + * (`packages/contracts/src/generated/enums.ts`, `PostStatusSchema`) and pinned in + * `contracts/audience-post-status.contract.json`. + * + * ⚠ The spec's original list — `draft → generating → approval-pending → published` + * plus `rejected`/`failed` — was derived from a written digest rather than from the + * schema, and three of those six strings Audience never emits. The adapter + * implemented the spec faithfully and was therefore wrong: `awaiting_approval`, + * the approve-before-post gate, fell through to `default` and never blocked. + */ +export type AudiencePostStatus = + | 'draft' + | 'generating' + | 'ready_for_review' + | 'awaiting_approval' + | 'approved' + | 'publishing' + | 'fully_published' + | 'partially_published' + | 'failed'; + +/** One item of `GET /posts`'s `{ items: [...] }` envelope, as the API returns it. */ export interface AudiencePost { id: string; - status: string; // draft | generating | approval-pending | published | rejected | failed + /** Widened to `string` on purpose: the wire can carry a status this build has not + * heard of, and `pipelineFor` must classify it rather than crash. */ + status: string; text?: string; - platforms?: string[]; + createdAt?: string; + updatedAt?: string | null; } export interface AudienceReader { @@ -26,27 +52,48 @@ export interface AudienceReader { } // §6.2 mapping table — Audience native post status → canonical pipeline stage. -function pipelineFor(status: string): { +type Classification = { stage: 'Spec' | 'Build' | 'Live' | 'Archived' | null; gate: boolean; terminal: boolean; -} { - switch (status) { - case 'draft': - return { stage: 'Spec', gate: false, terminal: false }; - case 'generating': - return { stage: 'Build', gate: false, terminal: false }; - case 'approval-pending': - return { stage: null, gate: true, terminal: false }; // → Blocked - case 'published': - return { stage: 'Live', gate: false, terminal: false }; - case 'rejected': - return { stage: 'Archived', gate: false, terminal: false }; - case 'failed': - return { stage: null, gate: false, terminal: true }; // → Failed - default: - return { stage: null, gate: false, terminal: false }; - } +}; + +/** + * Exhaustive by construction: `Record` fails to compile if + * Audience adds a status and this table does not grow with it. That is the whole + * point — the previous `switch` had a silent `default`, so five of Audience's nine + * statuses classified as "nothing" and no test noticed. + */ +const CLASSIFY: Record = { + // Straight from the spec's mapping table. + draft: { stage: 'Spec', gate: false, terminal: false }, + generating: { stage: 'Build', gate: false, terminal: false }, + fully_published: { stage: 'Live', gate: false, terminal: false }, + failed: { stage: null, gate: false, terminal: true }, + + // The spec's "approve-before-post human gate", under its real name. + awaiting_approval: { stage: null, gate: true, terminal: false }, + + // In flight, no human owed anything. + approved: { stage: 'Build', gate: false, terminal: false }, + publishing: { stage: 'Build', gate: false, terminal: false }, + + // ⚠ Two product judgments the spec never contemplated, called out rather than + // buried. Both are gated on the principle that the board exists to surface what + // needs a person; change them here if the intent differs. + // ready_for_review — a human must look before it can advance to approval. + // partially_published — some targets published and some did not; someone has to + // decide about the rest, and `failed` (terminal) is wrong + // because part of it did ship. + ready_for_review: { stage: null, gate: true, terminal: false }, + partially_published: { stage: null, gate: true, terminal: false }, +}; + +function pipelineFor(status: string): Classification { + // An unknown status is classified as nothing rather than guessed at — the same + // posture as the old `default`, but now reachable only by a status genuinely + // absent from the pinned contract, not by five of the nine real ones. + return CLASSIFY[status as AudiencePostStatus] ?? { stage: null, gate: false, terminal: false }; } export interface AudienceAdapterOpts { @@ -112,7 +159,12 @@ export async function audienceCards( // Rollup policy: if per-post granularity would swamp the board, emit one card. if (posts.length > rollupThreshold) { - const awaiting = posts.filter((p) => p.status === 'approval-pending').length; + // Derived from the same classification the per-post path uses, never from a + // literal. This line previously hardcoded 'approval-pending' independently of + // `pipelineFor`, so it would have kept reporting 0 even after the mapping was + // corrected — and the rollup is precisely the high-volume case an operator + // relies on, where a missed gate hides the most work. + const awaiting = posts.filter((p) => pipelineFor(p.status).gate).length; const blocked: BlockedInfo | null = awaiting > 0 ? { gate: 'approval', action: `Approve ${awaiting} post${awaiting > 1 ? 's' : ''}`, deepLink: 'audience:///queue' } @@ -152,7 +204,7 @@ export async function audienceCards( source: AUDIENCE_SOURCE, name: p.text ? p.text.slice(0, 48) : `post ${p.id}`, stage, - detail: stage === 'Blocked' || override ? `${p.status} · ${(p.platforms ?? []).join(', ')}` : p.status, + detail: p.status, blocked, stageSource, override, diff --git a/cockpit/ui/src/lib/dashboard/adapters/contracts/audience-post-status.contract.json b/cockpit/ui/src/lib/dashboard/adapters/contracts/audience-post-status.contract.json new file mode 100644 index 0000000..de6a348 --- /dev/null +++ b/cockpit/ui/src/lib/dashboard/adapters/contracts/audience-post-status.contract.json @@ -0,0 +1,25 @@ +{ + "contract": "audience GET /v1/posts — post status vocabulary", + "version": 1, + "producer": "audience · packages/contracts/src/generated/enums.ts · PostStatusSchema", + "consumer": "command-center · cockpit/ui/src/lib/dashboard/adapters/audience.ts · pipelineFor", + "note": "Generated on the producer side from its schema. Copied here verbatim; the order is the producer's.", + "statuses": [ + "draft", + "generating", + "ready_for_review", + "awaiting_approval", + "approved", + "publishing", + "fully_published", + "partially_published", + "failed" + ], + "itemFields": { + "id": "string", + "status": "PostStatus", + "text": "string", + "createdAt": "string (iso)", + "updatedAt": "string (iso) | null" + } +} diff --git a/docs/superpowers/specs/2026-06-09-project-dashboard-design.md b/docs/superpowers/specs/2026-06-09-project-dashboard-design.md index 7216285..c7935ae 100644 --- a/docs/superpowers/specs/2026-06-09-project-dashboard-design.md +++ b/docs/superpowers/specs/2026-06-09-project-dashboard-design.md @@ -254,9 +254,20 @@ written against a small interface so the two read paths are swappable without to ### 6.2 Audience adapter — post-run + backend status -**Native vocabulary** (from the Audience digest): post status -(`draft → generating → approval-pending → published`, plus `rejected`/`failed`) read via -`GET /posts` / `GET /posts/:id`; plus **backend liveness** via `GET /health` (`:8080`). +> **⚠ Corrected 2026-09-08 — the vocabulary below was wrong, and the adapter was wrong because it +> was faithful to it.** It was taken *"from the Audience digest"* — a prose summary — rather than +> from Audience's own generated `PostStatusSchema`. Three of the six strings +> (`approval-pending`, `published`, `rejected`) are ones Audience has **never** emitted, and five of +> its nine real statuses had no row at all. The practical cost: `awaiting_approval` — this table's +> own approve-before-post gate — fell through to `default` and **never blocked**, on the per-post +> path and again in the rollup, which counted the fictional string independently. Pinned now in +> `adapters/contracts/audience-post-status.contract.json`. + +**Native vocabulary** (from `audience/packages/contracts/src/generated/enums.ts`, `PostStatusSchema`): +post status is one of `draft`, `generating`, `ready_for_review`, `awaiting_approval`, `approved`, +`publishing`, `fully_published`, `partially_published`, `failed` — read via `GET /posts` (which +returns an `{ items: [...] }` envelope of `{id, status, text, createdAt, updatedAt}`; note it carries +**no** `platforms` field) / `GET /posts/:id`; plus **backend liveness** via `GET /health` (`:8080`). **Mapping to canonical stage:** @@ -265,11 +276,18 @@ written against a small interface so the two read paths are swappable without to | backend `/health` down | Idle + `health: "unknown"` | stack not running ⇒ no live project state | | `draft` | Spec | composed, not yet generating | | `generating` | Build | AI generation in flight | -| `approval-pending` | **Blocked** (`gate: "approval"`, deep-link `/queue`) | the approve-before-post human gate | -| `published` | Live | posted to platforms | -| `rejected` | Archived | (`detail: "rejected"`) | +| `ready_for_review` | **Blocked** (`gate: "approval"`) | ⚠ *judgment, 2026-09-08* — a human must look before it can advance. The board exists to surface what needs a person | +| `awaiting_approval` | **Blocked** (`gate: "approval"`, deep-link `/queue`) | the approve-before-post human gate — this row's original intent, under its real name | +| `approved` | Build | cleared the gate, publish pending; nobody is owed anything | +| `publishing` | Build | in flight to the platforms | +| `fully_published` | Live | posted to every target | +| `partially_published` | **Blocked** (`gate: "approval"`) | ⚠ *judgment, 2026-09-08* — some targets shipped and some did not; `failed` is wrong because part of it is live, and someone must decide about the rest | | `failed` | Failed | terminal publish failure | +*The two rows marked ⚠ are product decisions this spec never contemplated, recorded rather than +buried. Both are gated on the principle that the board surfaces what needs a person; change them in +`CLASSIFY` if the intent differs.* + Audience contributes **its own status** (per `§4` of the roadmap) — one `ProjectCard` per active post run, or a single rolled-up card "Audience: N awaiting approval" if per-post granularity is too noisy (the adapter chooses; the board renders whatever cards it gets). Reads go through the same