Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions cockpit/ui/src/lib/dashboard/adapters/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,25 +85,43 @@ 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 });
expect(cards[0].stage, status).toBe(expected);
}
});

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');
Expand All @@ -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');
Expand Down
55 changes: 55 additions & 0 deletions cockpit/ui/src/lib/dashboard/adapters/audience.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<AudiencePostStatus, true> = {
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);
}
});
});
96 changes: 74 additions & 22 deletions cockpit/ui/src/lib/dashboard/adapters/audience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<AudiencePostStatus, …>` 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<AudiencePostStatus, Classification> = {
// 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 {
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
30 changes: 24 additions & 6 deletions docs/superpowers/specs/2026-06-09-project-dashboard-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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
Expand Down
Loading