feat: per-connection publish governance — auto, 1-admin, majority, consensus

Per the owner's spec: each social connection gets publishGovernance in its
fields — unset = manual publish (today), auto = dispatch on approval,
admin1/admin_majority/admin_consensus = a publish-phase chatgov poll
(subject draft_publish, its own open-poll slot, no collision with review
gates). Dispatch holds until the publish poll passes (E3 pre-dispatch
check); auto dispatches on approval. Includes migration 033, the PATCH
/connections/:id/publish-governance route (chatgov.manage), and 19+31 new
tests. Implementation by the branding-panel process (claude/opus), lint
and integration fixes here.
This commit is contained in:
glass beadian 2026-10-14 08:04:56 +00:00
parent f2eb14956e
commit b89dea5c93
26 changed files with 1637 additions and 31 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge, Button } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
@ -9,7 +9,7 @@ import { api, errorMessage } from "../../../lib/api-client";
import { PLATFORMS } from "../../../lib/platforms";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types";
import type { ConnectionSummary, DraftRecord, PublicationsResponse } from "../../../lib/api-types";
/**
* Calendar = the scheduled slice of the publishing pipeline. There is no
@ -119,6 +119,19 @@ function relativeLabel(iso: string, now: number): { text: string; overdue: boole
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
}
/**
* The newest dispatch receipt wins: the API orders publications by attempt
* number descending, and a retry's outcome supersedes the earlier attempt.
*/
function latestPublication(response: PublicationsResponse | null | undefined): {
state: string;
remoteUrl: string | null;
} | null {
const latest = response?.publications[0];
if (latest === undefined) return null;
return { state: latest.state, remoteUrl: latest.remoteUrl ?? null };
}
export default function CalendarPage(): React.ReactElement {
const { can } = useAuth();
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement {
const [notice, setNotice] = useState<Notice | null>(null);
const [now, setNow] = useState<number>(Date.now());
// Dispatch receipts per draft: one GET per scheduled draft (the API has no
// bulk endpoint), refreshed with the list. Only drafts that actually have
// publications surface a badge.
const [publications, setPublications] = useState<Record<string, PublicationsResponse | null>>({});
const fetchPublications = useCallback((ids: string[]) => {
if (!live || ids.length === 0) return;
for (const id of ids) {
void api
.get<PublicationsResponse>(`/publishing/drafts/${id}/publications`)
.then((response) => setPublications((current) => ({ ...current, [id]: response })))
.catch(() => setPublications((current) => ({ ...current, [id]: null })));
}
}, [live]);
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
const groups = useMemo(() => groupByDay(rows), [rows]);
// The scheduled set as a stable string so the fetch effect below only fires
// when it actually changes, and the timer below always reads the latest ids.
const draftIds = rows.map((row) => row.id);
const draftIdsRef = useRef<string[]>([]);
draftIdsRef.current = draftIds;
const draftIdsKey = draftIds.join(",");
// Fetch receipts on load and whenever the scheduled set changes (a new
// schedule appears, or one is cancelled).
useEffect(() => {
fetchPublications(draftIdsKey === "" ? [] : draftIdsKey.split(","));
}, [fetchPublications, draftIdsKey]);
// Same cadence as the streams wall: refresh on a 30s timer.
useEffect(() => {
const timer = setInterval(() => {
drafts.reload();
connections.reload();
fetchPublications(draftIdsRef.current);
setNow(Date.now());
}, 30_000);
return () => clearInterval(timer);
}, [drafts.reload, connections.reload]);
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
const groups = useMemo(() => groupByDay(rows), [rows]);
}, [drafts.reload, connections.reload, fetchPublications]);
async function cancel(row: ScheduledRow): Promise<void> {
if (!live) {
@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement {
<span className="ml-auto text-2xs text-text-faint">
{rows.length} scheduled · refreshes every 30s
</span>
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); fetchPublications(draftIdsRef.current); setNow(Date.now()); }}>
Refresh
</Button>
</div>
<p className="mb-4 text-xs text-text-faint">
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
UTC time beside it; execution state lives in the audit log, not this list.
UTC time beside it; drafts with dispatch receipts show their latest outcome, with the remote link when the
provider returned one.
</p>
{notice !== null && (
@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
{group.rows.map((row) => {
const relative = relativeLabel(row.publishAt, now);
const latest = latestPublication(publications[row.id]);
return (
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
<div className="flex w-44 shrink-0 flex-col gap-0.5">
@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
<Badge tone="accent">scheduled</Badge>
{latest !== null && (
<Badge tone={latest.state === "completed" ? "live" : latest.state === "failed" ? "danger" : "warn"}>
{latest.state.replace(/_/g, " ")}
{latest.remoteUrl !== null && (
<a href={latest.remoteUrl} target="_blank" rel="noreferrer" className="underline decoration-current/40 underline-offset-2 hover:decoration-current">
open ↗
</a>
)}
</Badge>
)}
<span>{row.targetLabel}</span>
{!row.fromScheduledAt && (
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">

View file

@ -164,3 +164,26 @@ export interface ScheduledPublicationRecord {
publishAt: string;
state: "pending" | "in_flight" | "done" | "failed";
}
/** One dispatch attempt for a draft, newest first — GET /publishing/drafts/:draftId/publications. */
export interface DraftPublication {
id: string;
connectionId: string;
attemptNo: string;
dispatchCount: number;
state: "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned";
createdAt: string;
updatedAt: string;
firstDispatchStartedAt?: string;
dispatchStartedAt?: string;
providerRetryDeadline?: string;
receivedAt?: string;
publishedAt?: string;
remoteId?: string;
remoteUrl?: string;
error?: string;
}
export interface PublicationsResponse {
publications: DraftPublication[];
}