@anthusai/papyrus 5.32.0 → 5.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +112 -60
  4. package/amplify/data/resource.ts +115 -60
  5. package/amplify/data/schema.js +104 -56
  6. package/amplify/data/schema.ts +107 -56
  7. package/amplify/functions/automation-view/handler.py +9 -3
  8. package/amplify/functions/automation-view/schema.js +13 -3
  9. package/amplify/functions/automation-view/schema.ts +13 -3
  10. package/amplify/functions/automation-view/wire.js +28 -4
  11. package/amplify/functions/automation-view/wire.ts +34 -4
  12. package/amplify/functions/editorial-checks/schema.js +9 -8
  13. package/amplify/functions/editorial-checks/schema.ts +9 -8
  14. package/amplify/functions/monitoring-view/schema.js +2 -1
  15. package/amplify/functions/monitoring-view/schema.ts +2 -1
  16. package/amplify/plugins/newsletter/schema.js +2 -1
  17. package/amplify/plugins/newsletter/schema.ts +2 -1
  18. package/amplify/plugins/reader-steering/config.js +3 -1
  19. package/amplify/plugins/reader-steering/config.ts +11 -1
  20. package/amplify/plugins/reader-steering/papyrus.plugin.js +2 -0
  21. package/amplify/plugins/reader-steering/papyrus.plugin.ts +2 -0
  22. package/amplify/plugins/reader-steering/schema.js +4 -3
  23. package/amplify/plugins/reader-steering/schema.ts +4 -3
  24. package/amplify/plugins/signup/schema.js +2 -1
  25. package/amplify/plugins/signup/schema.ts +2 -1
  26. package/components/automation-panel.tsx +12 -2
  27. package/components/automation-run-now.tsx +97 -0
  28. package/components/automation-view.tsx +50 -7
  29. package/components/newsroom-ops-overview.tsx +18 -0
  30. package/components/notification-center.tsx +262 -0
  31. package/components/topic-steering-workspace.tsx +4 -1
  32. package/lib/automation-client.ts +26 -1
  33. package/lib/automation-fixture.ts +46 -18
  34. package/lib/automation-overview.ts +96 -2
  35. package/lib/review-client.ts +99 -0
  36. package/lib/review-fixture.ts +45 -0
  37. package/lib/review-requests.ts +238 -0
  38. package/package.json +6 -1
  39. package/routes.manifest.json +1 -1
@@ -1,6 +1,10 @@
1
1
  import Link from "next/link";
2
+ import { RunNowControl } from "./automation-run-now";
2
3
  import {
3
4
  ENABLED_NOTE,
5
+ OUTSIDE_NOTICE,
6
+ atATimeText,
7
+ costText,
4
8
  durationText,
5
9
  enabledText,
6
10
  formatUtc,
@@ -11,9 +15,12 @@ import {
11
15
  statusText,
12
16
  storyHref,
13
17
  triggerText,
18
+ whoText,
19
+ type AutomationOutsideRun,
14
20
  type AutomationRecord,
15
21
  type AutomationResult,
16
22
  type AutomationRun,
23
+ type RunNowResult,
17
24
  } from "../lib/automation-overview";
18
25
 
19
26
  function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
@@ -30,7 +37,8 @@ function RunLine({ run, demo }: { run: AutomationRun; demo: boolean }) {
30
37
  <li data-automation-run={run.runId ?? run.sourceId ?? "unlinked"} data-run-status={run.status ?? "unknown"}>
31
38
  <strong>{formatUtc(run.startedAt)}</strong>
32
39
  <span><span aria-hidden="true">{statusMark(run.status)} </span>Status: {statusText(run.status)} &middot; {durationText(run.durationSeconds)}</span>
33
- <span>Item: {run.sourceId ?? "unknown"}</span>
40
+ <span>{whoText(run)}</span>
41
+ {run.reason ? <span data-automation-run-reason>Reason: {run.reason}</span> : null}
34
42
  {run.error ? <span data-automation-run-error>Error: {run.error}</span> : null}
35
43
  <span>
36
44
  {run.runId ? <>Run {run.runId} (<Link href={proceduresHref(demo)}>Procedures</Link>)</> : run.triggerStatus === "started" ? "Run record not linked yet" : "No run record"}
@@ -40,12 +48,30 @@ function RunLine({ run, demo }: { run: AutomationRun; demo: boolean }) {
40
48
  <span>
41
49
  {run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)} data-automation-story-link>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
42
50
  </span>
43
- <span>Cost: unknown ({run.costNote})</span>
51
+ <span data-automation-run-cost data-over-budget={run.overBudget || undefined}>
52
+ <span aria-hidden="true">{run.overBudget ? "! " : ""}</span>{costText(run)}
53
+ </span>
44
54
  </li>
45
55
  );
46
56
  }
47
57
 
48
- function Detail({ record, demo }: { record: AutomationRecord; demo: boolean }) {
58
+ function OutsideRunLine({ run, demo }: { run: AutomationOutsideRun; demo: boolean }) {
59
+ return (
60
+ <li data-automation-outside-run={run.runId ?? "unlinked"} data-run-status={run.status ?? "unknown"}>
61
+ <strong>{formatUtc(run.startedAt)}</strong>
62
+ <span><span aria-hidden="true">{statusMark(run.status)} </span>Status: {statusText(run.status)} &middot; {durationText(run.durationSeconds)}</span>
63
+ <span>Started by: {run.requestedBy ?? "unknown"}</span>
64
+ {run.error ? <span data-automation-run-error>Error: {run.error}</span> : null}
65
+ <span>{run.runId ? <>Run {run.runId} (<Link href={proceduresHref(demo)}>Procedures</Link>)</> : "No run id"}</span>
66
+ <span>
67
+ {run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)}>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
68
+ </span>
69
+ <span data-automation-run-cost>{costText({ ...run, budgetUsd: null })}</span>
70
+ </li>
71
+ );
72
+ }
73
+
74
+ function Detail({ record, demo, onRunNow }: { record: AutomationRecord; demo: boolean; onRunNow?: RunNowHandler }) {
49
75
  const notice = noticeText(record.notice);
50
76
  return (
51
77
  <article className="news-desk-doctrine-card" data-automation-detail={record.id}>
@@ -55,10 +81,11 @@ function Detail({ record, demo }: { record: AutomationRecord; demo: boolean }) {
55
81
  <div><dt>Procedure</dt><dd>{record.procedureKey}</dd></div>
56
82
  <div><dt>Input</dt><dd>{Object.keys(record.inputTemplate).length ? Object.entries(record.inputTemplate).map(([name, template]) => `${name} = ${template}`).join("; ") : "none"}</dd></div>
57
83
  <div><dt>Enabled</dt><dd>{enabledText(record)} ({ENABLED_NOTE})</dd></div>
58
- <div><dt>Limits</dt><dd>timeout {record.limits.timeoutSeconds ?? "?"} s, {record.limits.maxConcurrent ?? "?"} at a time{record.limits.budgetUsdPerRun !== undefined ? `, about $${record.limits.budgetUsdPerRun} per run` : ""} ({record.limitsNote})</dd></div>
84
+ <div><dt>Limits</dt><dd>timeout {record.limits.timeoutSeconds ?? "?"} s, {atATimeText(record.limits)}{record.limits.budgetUsdPerRun !== undefined ? `, about $${record.limits.budgetUsdPerRun} per run, advisory` : ""} ({record.limitsNote})</dd></div>
59
85
  <div><dt>Router</dt><dd data-automation-link={record.link?.state ?? "unchecked"}>{linkText(record.link)}</dd></div>
60
86
  </dl>
61
87
  {notice ? <p className="news-desk-delegation-note" data-automation-notice>{notice}</p> : null}
88
+ <RunNowControl record={record} demo={demo} onRun={onRunNow} />
62
89
  <h4>Recent runs</h4>
63
90
  {record.runs.length ? (
64
91
  <ul className="news-desk-monitoring__list" data-automation-runs>
@@ -70,6 +97,18 @@ function Detail({ record, demo }: { record: AutomationRecord; demo: boolean }) {
70
97
  {record.skippedAlreadyHadReference} {record.skippedAlreadyHadReference === 1 ? "item was" : "items were"} not started because the Reference already existed.
71
98
  </p>
72
99
  ) : null}
100
+ {record.outsideRuns.length || record.outsideNotice ? (
101
+ <>
102
+ <h4>Started outside this automation</h4>
103
+ <p className="news-desk-delegation-note">Other runs of {record.procedureKey} that this automation did not start.</p>
104
+ {record.outsideNotice ? <p className="news-desk-delegation-note" data-automation-notice>{OUTSIDE_NOTICE}</p> : null}
105
+ {record.outsideRuns.length ? (
106
+ <ul className="news-desk-monitoring__list" data-automation-outside-runs>
107
+ {record.outsideRuns.map((run) => <OutsideRunLine key={run.runId ?? run.startedAt ?? "run"} run={run} demo={demo} />)}
108
+ </ul>
109
+ ) : null}
110
+ </>
111
+ ) : null}
73
112
  </article>
74
113
  );
75
114
  }
@@ -95,16 +134,20 @@ function ListItem({ record, selected, onSelect }: { record: AutomationRecord; se
95
134
  }
96
135
 
97
136
  /** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
137
+ export type RunNowHandler = (automationId: string, input: Record<string, unknown> | null) => Promise<RunNowResult>;
138
+
98
139
  export function AutomationView({
99
140
  result,
100
141
  selectedId,
101
142
  onSelect,
102
143
  demo = false,
144
+ onRunNow,
103
145
  }: {
104
146
  result: AutomationResult | null;
105
147
  selectedId: string | null;
106
148
  onSelect: (id: string) => void;
107
149
  demo?: boolean;
150
+ onRunNow?: RunNowHandler;
108
151
  }) {
109
152
  let body: React.ReactNode;
110
153
  if (result === null) {
@@ -128,7 +171,7 @@ export function AutomationView({
128
171
  </ul>
129
172
  {result.monitoring === "not_configured" ? <p className="news-desk-delegation-note" data-automation-notice>{noticeText("monitoring_not_configured")}</p> : null}
130
173
  </article>
131
- {selected ? <Detail record={selected} demo={demo} /> : <p className="news-desk-delegation-note" data-automation-pick>Choose an automation to see its recent runs.</p>}
174
+ {selected ? <Detail record={selected} demo={demo} onRunNow={onRunNow} /> : <p className="news-desk-delegation-note" data-automation-pick>Choose an automation to see its recent runs.</p>}
132
175
  </>
133
176
  );
134
177
  }
@@ -138,10 +181,10 @@ export function AutomationView({
138
181
  <div className="news-desk-doctrine-card__header">
139
182
  <div>
140
183
  <h3>Automation</h3>
141
- <span>Procedures that start on their own. Read only here; an operator changes them with the command line.</span>
184
+ <span>Procedures that start on their own. Editors and admins can run one now; an operator changes the settings with the command line.</span>
142
185
  </div>
143
186
  </div>
144
- {result && "asOf" in result ? <p className="news-desk-delegation-note" data-automation-as-of>As of {formatUtc(result.asOf)}. The enabled setting is {ENABLED_NOTE}.</p> : null}
187
+ {result && "asOf" in result ? <p className="news-desk-delegation-note" data-automation-as-of>As of {formatUtc(result.asOf)}. Enabled: {ENABLED_NOTE}.</p> : null}
145
188
  </article>
146
189
  {body}
147
190
  </div>
@@ -33,15 +33,23 @@ import {
33
33
  sortNewsroomSections,
34
34
  } from "../lib/newsroom-sections";
35
35
  import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
36
+ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
37
+ import { NotificationCenter } from "./notification-center";
36
38
 
37
39
  type NewsroomOpsOverviewProps = {
38
40
  assignments: AssignmentRecord[];
41
+ actorLabel?: string;
42
+ decisions?: ReadonlyMap<string, RelevanceDecisionView>;
39
43
  demo?: boolean;
44
+ humanCuratedReferenceLineageIds?: ReadonlySet<string>;
40
45
  messages: MessageRecord[];
41
46
  newsroomSections: NewsroomSectionRecord[];
42
47
  references: ReferenceRecord[];
43
48
  };
44
49
 
50
+ const NO_DECISIONS: ReadonlyMap<string, RelevanceDecisionView> = new Map();
51
+ const NO_IDS: ReadonlySet<string> = new Set();
52
+
45
53
  type DestinationKey = Exclude<NewsroomNavTabId, "overview" | "desks">;
46
54
 
47
55
  const DESTINATION_ICONS: Record<DestinationKey, typeof BookOpenIcon> = {
@@ -75,8 +83,11 @@ function recentMessageCount(messages: MessageRecord[]): number {
75
83
  }
76
84
 
77
85
  export function NewsroomOpsOverview({
86
+ actorLabel = "",
78
87
  assignments,
88
+ decisions = NO_DECISIONS,
79
89
  demo = false,
90
+ humanCuratedReferenceLineageIds = NO_IDS,
80
91
  messages,
81
92
  newsroomSections,
82
93
  references,
@@ -117,6 +128,13 @@ export function NewsroomOpsOverview({
117
128
  data-newsroom-ops-home="true"
118
129
  >
119
130
  <div className="space-y-8" data-newsroom-overview-feeds>
131
+ <NotificationCenter
132
+ actorLabel={actorLabel}
133
+ decisions={decisions}
134
+ demo={demo}
135
+ humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
136
+ references={canonicalReferences}
137
+ />
120
138
  <section aria-label="Destinations" className="space-y-3">
121
139
  <h2 className="text-sm font-medium text-muted-foreground">Go to</h2>
122
140
  <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -0,0 +1,262 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { Badge } from "./ui/badge";
6
+ import { Button } from "./ui/button";
7
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
8
+ import {
9
+ cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
10
+ steeringText, subjectHref, summarize, summaryText,
11
+ type AnswerInput, type CyclotronEntry, type ReviewRequest,
12
+ } from "../lib/review-requests";
13
+ import type { ReferenceRecord } from "../lib/category-repository";
14
+ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
15
+ import { FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
16
+
17
+ export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
18
+
19
+ type ViewProps = {
20
+ loadState: NotificationLoadState;
21
+ requests: ReviewRequest[];
22
+ readIds: ReadonlySet<string>;
23
+ held: CyclotronEntry[];
24
+ autoDecided: CyclotronEntry[];
25
+ demo?: boolean;
26
+ busyId?: string | null;
27
+ error?: string | null;
28
+ onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
29
+ onMarkRead?: (requestId: string) => void;
30
+ onMarkAllRead?: () => void;
31
+ };
32
+
33
+ function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
34
+ request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean;
35
+ onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
36
+ }) {
37
+ const [note, setNote] = useState("");
38
+ const href = subjectHref(request.subject, demo);
39
+ const open = request.state === "open";
40
+ const steering = steeringText(request);
41
+ const act = (input: AnswerInput) => onAnswer?.(request, { ...input, feedback: input.feedback ?? (note.trim() || null) });
42
+ return (
43
+ <li className="rounded-lg border p-3 space-y-2" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
44
+ <div className="flex flex-wrap items-center gap-2">
45
+ <Badge variant="outline"><span aria-hidden="true">{modeMark(request.mode)} </span>{modeLabel(request.mode)}</Badge>
46
+ {unread ? <Badge variant="default"><span aria-hidden="true">● </span>New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
47
+ <strong className="text-sm">{request.reason}</strong>
48
+ </div>
49
+ <p className="text-xs text-muted-foreground">
50
+ Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
51
+ {" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{request.subject.type.replace("_", " ")} {request.subject.id}</Link> : <span>{request.subject.type} {request.subject.id}</span>}
52
+ </p>
53
+ {request.ask ? <p className="text-sm">{request.ask}</p> : null}
54
+ {request.links.length ? (
55
+ <ul className="text-xs space-y-0.5" aria-label="Items in this request">
56
+ {request.links.slice(0, 10).map((link, index) => {
57
+ const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
58
+ return <li key={`${link.id ?? link.url}-${index}`}>{target ? <Link href={target}>{link.label ?? link.id ?? link.url}</Link> : (link.label ?? link.id)}</li>;
59
+ })}
60
+ {request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
61
+ </ul>
62
+ ) : null}
63
+ <p className="text-sm" data-review-next>{nextText(request)}</p>
64
+ {steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
65
+ {open ? (
66
+ <div className="space-y-2" data-review-controls>
67
+ <label className="block text-xs text-muted-foreground">
68
+ Steering note (optional)
69
+ <textarea
70
+ className="mt-1 block w-full rounded-md border bg-background p-2 text-sm"
71
+ disabled={busy}
72
+ onChange={(event) => setNote(event.target.value)}
73
+ placeholder={request.mode === "flag" ? "Tell the step what to do differently" : "What should change, or what did you want instead?"}
74
+ rows={2}
75
+ value={note}
76
+ />
77
+ </label>
78
+ <div className="flex flex-wrap gap-2">
79
+ {request.mode === "flag" ? (
80
+ <Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Acknowledge</Button>
81
+ ) : (
82
+ <>
83
+ <Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
84
+ <Button disabled={busy} onClick={() => act({ decision: "reject" })} size="sm" variant="outline">Reject</Button>
85
+ </>
86
+ )}
87
+ <Button disabled={busy || !note.trim()} onClick={() => act({ decision: "feedback", steer: "adjust" })} size="sm" variant="outline">Steer with this note</Button>
88
+ <Button disabled={busy} onClick={() => act({ decision: "feedback", steer: "not_this_kind" })} size="sm" variant="outline">Not this kind of thing</Button>
89
+ {unread ? <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button> : null}
90
+ </div>
91
+ </div>
92
+ ) : unread ? (
93
+ <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
94
+ ) : null}
95
+ </li>
96
+ );
97
+ }
98
+
99
+ function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }) {
100
+ const confidence = entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`;
101
+ return (
102
+ <li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
103
+ <div className="flex flex-wrap items-center gap-2">
104
+ <Badge variant="outline"><span aria-hidden="true">{held ? "◔" : "✓"} </span>{held ? "Held for review" : "Decided by the system"}</Badge>
105
+ <strong className="text-sm">{entry.title}</strong>
106
+ </div>
107
+ <p className="text-xs text-muted-foreground">
108
+ Classified as {entry.label} · {confidence}{entry.decidedAt ? ` · ${formatWhen(entry.decidedAt)}` : ""}
109
+ {entry.reviewedByPerson ? " · reviewed by a person" : ""}
110
+ </p>
111
+ {held ? <p className="text-sm">{entry.reviewDetail ? `${entry.reviewDetail}. ` : ""}Held for review: it stays pending until someone decides.</p> : null}
112
+ <Link className="text-sm" href={entry.href}>{held ? "Review it in References" : "Open it in References to correct it"}</Link>
113
+ </li>
114
+ );
115
+ }
116
+
117
+ function Section({ id, title, count, children, empty }: { id: string; title: string; count: number; children: React.ReactNode; empty: string }) {
118
+ return (
119
+ <section aria-labelledby={`notification-${id}`} className="space-y-2" data-notification-section={id}>
120
+ <h2 className="text-sm font-medium" id={`notification-${id}`}>{title} ({count})</h2>
121
+ {count ? children : <p className="text-sm text-muted-foreground">{empty}</p>}
122
+ </section>
123
+ );
124
+ }
125
+
126
+ /** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
127
+ export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
128
+ const sections = useMemo(() => sectionReviews(requests), [requests]);
129
+ const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
130
+ const summary = summarize(sections, held, autoDecided, unreadCount);
131
+ const entry = (request: ReviewRequest) => (
132
+ <Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
133
+ );
134
+ if (loadState === "loading") return <div role="status" data-notification-state="loading"><strong>Loading the Notification Center</strong></div>;
135
+ if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
136
+ if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. Nothing waiting on a person has changed.</p></div>;
137
+ return (
138
+ <div className="space-y-6" data-notification-center>
139
+ <header className="space-y-1">
140
+ <h1 className="text-lg font-semibold">Notification Center</h1>
141
+ <p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
142
+ <p className="text-xs text-muted-foreground">
143
+ Look, or steer. Anything you ignore keeps going: soft reviews proceed at their deadline and flags never wait. Only a hard gate waits for you.
144
+ </p>
145
+ <p className="text-xs text-muted-foreground" data-notification-unread>
146
+ {unreadCount ? `${unreadCount} unread` : "Nothing unread"}
147
+ {unreadCount ? <Button className="ml-2" onClick={onMarkAllRead} size="sm" variant="ghost">Mark all read</Button> : null}
148
+ </p>
149
+ {error ? <p role="alert" className="text-sm" data-notification-error>{error}</p> : null}
150
+ </header>
151
+
152
+ <Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
153
+ <ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
154
+ </Section>
155
+ <Section count={sections.softReviews.length} empty="No soft reviews are running." id="soft-reviews" title="Proceeding soon: soft reviews, soonest deadline first">
156
+ <ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
157
+ </Section>
158
+ <Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
159
+ <ul className="space-y-2">{held.slice(0, 10).map((item) => <CyclotronLine entry={item} held key={item.lineageId} />)}</ul>
160
+ {held.length > 10 ? <p className="text-sm">and {held.length - 10} more. <Link href={getNewsroomNavHref("/newsroom/references", demo)}>See all in References</Link></p> : null}
161
+ </Section>
162
+ <Section count={sections.flags.length} empty="No flags." id="flags" title="Flags: for your information">
163
+ <ul className="space-y-2">{sections.flags.map(entry)}</ul>
164
+ </Section>
165
+ <Section count={sections.decided.length} empty="Nothing answered or proceeded yet." id="decided" title="Answered, and what became of it">
166
+ <ul className="space-y-2">{sections.decided.slice(0, 20).map(entry)}</ul>
167
+ </Section>
168
+ <details data-notification-section="auto-decided">
169
+ <summary className="cursor-pointer text-sm font-medium">Recently decided by the system ({autoDecided.length})</summary>
170
+ <div className="mt-2 space-y-2">
171
+ <p className="text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
172
+ {autoDecided.length ? <ul className="space-y-2">{autoDecided.map((item) => <CyclotronLine entry={item} held={false} key={item.lineageId} />)}</ul> : <p className="text-sm text-muted-foreground">No automatic decisions yet.</p>}
173
+ </div>
174
+ </details>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ type ContainerProps = {
180
+ references: ReferenceRecord[];
181
+ decisions: ReadonlyMap<string, RelevanceDecisionView>;
182
+ humanCuratedReferenceLineageIds: ReadonlySet<string>;
183
+ actorLabel: string;
184
+ demo: boolean;
185
+ };
186
+
187
+ const REFRESH_MILLISECONDS = 60_000;
188
+
189
+ export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo }: ContainerProps) {
190
+ const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
191
+ const [requests, setRequests] = useState<ReviewRequest[]>([]);
192
+ const [readIds, setReadIds] = useState<Set<string>>(new Set());
193
+ const [userKey, setUserKey] = useState<string | null>(null);
194
+ const [busyId, setBusyId] = useState<string | null>(null);
195
+ const [error, setError] = useState<string | null>(null);
196
+
197
+ const refresh = useCallback(async () => {
198
+ if (demo) {
199
+ setRequests(FIXTURE_REVIEW_REQUESTS);
200
+ setReadIds(new Set(["rr-flag-1"]));
201
+ setLoadState("ready");
202
+ return;
203
+ }
204
+ try {
205
+ const { loadReviews } = await import("../lib/review-client");
206
+ const loaded = await loadReviews();
207
+ if (loaded.state !== "ready") {
208
+ setLoadState(loaded.state);
209
+ return;
210
+ }
211
+ setRequests(loaded.requests);
212
+ setReadIds(new Set(loaded.readIds));
213
+ setUserKey(loaded.userKey);
214
+ setLoadState("ready");
215
+ } catch {
216
+ setLoadState("unavailable");
217
+ }
218
+ }, [demo]);
219
+
220
+ useEffect(() => {
221
+ void refresh();
222
+ const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
223
+ return () => window.clearInterval(timer);
224
+ }, [refresh]);
225
+
226
+ const held = useMemo(() => cyclotronHeldForReview(references, decisions, demo), [references, decisions, demo]);
227
+ const autoDecided = useMemo(() => cyclotronAutoDecided(references, decisions, humanCuratedReferenceLineageIds, demo), [references, decisions, humanCuratedReferenceLineageIds, demo]);
228
+
229
+ const markRead = useCallback(async (requestId: string) => {
230
+ setReadIds((current) => new Set(current).add(requestId));
231
+ if (demo || !userKey) return;
232
+ const { markReviewRead } = await import("../lib/review-client");
233
+ await markReviewRead(userKey, requestId).catch(() => undefined);
234
+ }, [demo, userKey]);
235
+
236
+ const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
237
+ setError(null);
238
+ setBusyId(request.id);
239
+ try {
240
+ if (demo) throw new Error("This is a demo: answers are not saved.");
241
+ const { answerReviewRequest } = await import("../lib/review-client");
242
+ const saved = await answerReviewRequest(request, input, actorLabel);
243
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
244
+ await markRead(request.id);
245
+ } catch (failure) {
246
+ setError(failure instanceof Error ? failure.message : "Could not save the answer.");
247
+ } finally {
248
+ setBusyId(null);
249
+ }
250
+ }, [actorLabel, demo, markRead]);
251
+
252
+ const markAllRead = useCallback(() => {
253
+ for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
254
+ }, [markRead, readIds, requests]);
255
+
256
+ return (
257
+ <NotificationCenterView
258
+ autoDecided={autoDecided} busyId={busyId} demo={demo} error={error} held={held} loadState={loadState}
259
+ onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
260
+ />
261
+ );
262
+ }
@@ -3004,7 +3004,7 @@ function NewsDeskDashboard({
3004
3004
  return entries;
3005
3005
  }, [summaryStatus, tabCounts]);
3006
3006
  const opsPageTitle = activeTab === "overview"
3007
- ? "Overview"
3007
+ ? "Notification Center"
3008
3008
  : activeTab === "administration" && administrationPanel === "sections"
3009
3009
  ? "Sections"
3010
3010
  : formatDeskSectionHeadline(activeTab);
@@ -3062,8 +3062,11 @@ function NewsDeskDashboard({
3062
3062
 
3063
3063
  {activeTab === "overview" ? (
3064
3064
  <NewsroomOpsOverview
3065
+ actorLabel={authState.label}
3065
3066
  assignments={assignments}
3067
+ decisions={relevanceDecisions}
3066
3068
  demo={Boolean(dashboard.isDemo)}
3069
+ humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3067
3070
  messages={messages}
3068
3071
  newsroomSections={newsroomSections}
3069
3072
  references={references}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { generateClient } from "aws-amplify/data";
4
- import type { AutomationResult } from "./automation-overview";
4
+ import type { AutomationResult, RunNowResult } from "./automation-overview";
5
5
 
6
6
  type GraphQLResult = { data?: { automationOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
7
7
 
@@ -27,3 +27,28 @@ export async function loadAutomationOverview(): Promise<AutomationResult> {
27
27
  const raw = result.data.automationOverview;
28
28
  return (typeof raw === "string" ? JSON.parse(raw) : raw) as AutomationResult;
29
29
  }
30
+
31
+ type RunGraphQLResult = { data?: { runAutomation?: unknown }; errors?: { message?: string; errorType?: string }[] };
32
+
33
+ /**
34
+ * Starts a run of the automation's procedure now, as the signed-in editor or admin. The function behind the
35
+ * mutation checks the groups again, refuses a disabled automation or one at its limit, and writes the row that
36
+ * links the run to the automation's history. `input` is sent as JSON text; null uses the record's own input.
37
+ */
38
+ export async function runAutomationNow(automationId: string, input: Record<string, unknown> | null): Promise<RunNowResult> {
39
+ const client = generateClient({ authMode: "userPool" });
40
+ let result: RunGraphQLResult;
41
+ try {
42
+ result = (await client.graphql({
43
+ query: `mutation RunAutomation($automationId: String!, $input: AWSJSON) { runAutomation(automationId: $automationId, input: $input) }`,
44
+ variables: { automationId, input: input === null ? null : JSON.stringify(input) },
45
+ })) as RunGraphQLResult;
46
+ } catch (error) {
47
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as RunGraphQLResult;
48
+ }
49
+ const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
50
+ if (refused) return { error: "forbidden" };
51
+ if (result.errors?.length || result.data?.runAutomation === undefined || result.data.runAutomation === null) return { state: "unavailable" };
52
+ const raw = result.data.runAutomation;
53
+ return (typeof raw === "string" ? JSON.parse(raw) : raw) as RunNowResult;
54
+ }
@@ -1,6 +1,15 @@
1
- import type { AutomationOverview } from "./automation-overview";
1
+ import type { AutomationOverview, AutomationRun } from "./automation-overview";
2
2
 
3
- const COST_NOTE = "not recorded: the procedure runner does not record token use or cost on a ProcedureRun yet";
3
+ const NOT_RECORDED = "not recorded for this run";
4
+ const ESTIMATED = "estimated from the model usage reported by the run";
5
+
6
+ function run(fields: Partial<AutomationRun> & Pick<AutomationRun, "status" | "startedAt">): AutomationRun {
7
+ return {
8
+ origin: "monitor", requestedBy: null, reason: null, sourceId: null, referenceId: null, triggerStatus: fields.status, runId: null,
9
+ finishedAt: null, durationSeconds: null, error: null, runRecord: "none", storyId: null, storyVersionId: null, storyTitle: null,
10
+ costUsd: null, costNote: NOT_RECORDED, budgetUsd: null, overBudget: false, usage: null, ...fields,
11
+ };
12
+ }
4
13
 
5
14
  /** A realistic overview for tests and the `?demo=1` newsroom: one live monitor automation and one stored schedule. */
6
15
  export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
@@ -13,14 +22,15 @@ export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
13
22
  title: "Analyze new TTAB rulings",
14
23
  description: "Drafts a story for each new Board decision.",
15
24
  enabled: true,
16
- enabledNote: "display only until the router honours it",
25
+ enabledNote: "the router starts nothing for a disabled automation",
17
26
  trigger: { kind: "monitor", config: { monitorId: "ttab", publicationId: "example" } },
18
27
  supported: true,
19
28
  supportNote: null,
20
29
  procedureKey: "example.ttab.ruling-analysis",
21
30
  inputTemplate: { proceeding: "{sourceIdPrefix}", opinion_url: "{documentUrl}" },
22
- limits: { timeoutSeconds: 840, maxConcurrent: 1 },
23
- limitsNote: "recorded only: the runner does not enforce these yet",
31
+ limits: { timeoutSeconds: 840, maxConcurrent: 2, budgetUsdPerRun: 0.05 },
32
+ limitsNote: "the timeout and the number at a time (when set) are enforced when a run starts; the budget is advisory and only flagged",
33
+ runNow: { available: true, reason: null, needsInput: true, inputNames: ["opinion_url", "proceeding"] },
24
34
  createdAt: "2026-10-23T10:00:00Z",
25
35
  updatedAt: "2026-10-23T10:00:00Z",
26
36
  link: { state: "linked", text: "The router's trigger matches this record.", routerMode: "live", routerEnabled: true },
@@ -28,30 +38,44 @@ export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
28
38
  skippedAlreadyHadReference: 2,
29
39
  lastRun: null,
30
40
  runs: [
31
- { sourceId: "91000001-EXA_26", referenceId: "ref-1", triggerStatus: "completed", runId: "run-1", status: "completed",
32
- startedAt: "2026-10-23T11:30:00Z", finishedAt: "2026-10-23T11:33:10Z", durationSeconds: 190, error: null, runRecord: "found",
33
- storyId: "story-1", storyVersionId: "version-1", storyTitle: "Board affirms a refusal for likelihood of confusion", costUsd: null, costNote: COST_NOTE },
34
- { sourceId: "91000002-EXA_26", referenceId: "ref-2", triggerStatus: "failed", runId: "run-2", status: "failed",
35
- startedAt: "2026-10-23T05:30:00Z", finishedAt: "2026-10-23T05:31:00Z", durationSeconds: 60, error: "the opinion could not be downloaded", runRecord: "found",
36
- storyId: null, storyVersionId: null, storyTitle: null, costUsd: null, costNote: COST_NOTE },
37
- { sourceId: "91000003-EXA_26", referenceId: "ref-3", triggerStatus: "started", runId: null, status: "started",
38
- startedAt: "2026-10-23T11:59:00Z", finishedAt: null, durationSeconds: null, error: null, runRecord: "none",
39
- storyId: null, storyVersionId: null, storyTitle: null, costUsd: null, costNote: COST_NOTE },
41
+ run({ sourceId: "91000001-EXA_26", referenceId: "ref-1", runId: "run-1", status: "completed", triggerStatus: "completed",
42
+ startedAt: "2026-10-23T11:30:00Z", finishedAt: "2026-10-23T11:33:10Z", durationSeconds: 190, runRecord: "found",
43
+ storyId: "story-1", storyVersionId: "version-1", storyTitle: "Board affirms a refusal for likelihood of confusion",
44
+ costUsd: 0.0123, costNote: ESTIMATED, budgetUsd: 0.05,
45
+ usage: { calls: 4, promptTokens: 61000, completionTokens: 5200, models: { "gpt-6-luna": 4 }, complete: true, unpricedModels: [] } }),
46
+ run({ sourceId: "91000002-EXA_26", referenceId: "ref-2", runId: "run-2", status: "failed", triggerStatus: "failed",
47
+ startedAt: "2026-10-23T05:30:00Z", finishedAt: "2026-10-23T05:31:00Z", durationSeconds: 60, runRecord: "found",
48
+ error: "the opinion could not be downloaded" }),
49
+ run({ sourceId: "91000003-EXA_26", referenceId: "ref-3", status: "started", startedAt: "2026-10-23T11:59:00Z" }),
50
+ run({ origin: "manual", requestedBy: "ryan@example.test", runId: "run-4", status: "completed", triggerStatus: "completed",
51
+ startedAt: "2026-10-23T09:00:00Z", finishedAt: "2026-10-23T09:05:00Z", durationSeconds: 300, runRecord: "found",
52
+ costUsd: 0.081, costNote: ESTIMATED, budgetUsd: 0.05, overBudget: true }),
53
+ run({ sourceId: "91000005-EXA_26", referenceId: "ref-5", status: "deferred", startedAt: "2026-10-23T08:00:00Z",
54
+ reason: "deferred: concurrency limit: 2 running, at most 2 at a time" }),
55
+ run({ sourceId: "91000006-EXA_26", referenceId: "ref-6", status: "skipped", startedAt: "2026-10-22T08:00:00Z",
56
+ reason: "automation disabled" }),
57
+ ],
58
+ outsideRuns: [
59
+ { runId: "run-9", status: "completed", startedAt: "2026-10-22T15:00:00Z", finishedAt: "2026-10-22T15:02:00Z", durationSeconds: 120,
60
+ error: null, requestedBy: "papyrus-cli", storyId: null, storyVersionId: null, storyTitle: null, costUsd: null,
61
+ costNote: NOT_RECORDED, overBudget: false },
40
62
  ],
63
+ outsideNotice: null,
41
64
  },
42
65
  {
43
66
  id: "example.weekly-digest",
44
67
  title: "Weekly digest",
45
68
  description: null,
46
69
  enabled: false,
47
- enabledNote: "display only until the router honours it",
70
+ enabledNote: "the router starts nothing for a disabled automation",
48
71
  trigger: { kind: "schedule", config: { cron: "0 9 * * 1" } },
49
72
  supported: false,
50
73
  supportNote: "not yet supported",
51
74
  procedureKey: "example.weekly-digest",
52
75
  inputTemplate: {},
53
- limits: { timeoutSeconds: 840, maxConcurrent: 1 },
54
- limitsNote: "recorded only: the runner does not enforce these yet",
76
+ limits: { timeoutSeconds: 840 },
77
+ limitsNote: "the timeout and the number at a time (when set) are enforced when a run starts; the budget is advisory and only flagged",
78
+ runNow: { available: false, reason: "not yet supported", needsInput: false, inputNames: [] },
55
79
  createdAt: "2026-10-23T10:05:00Z",
56
80
  updatedAt: "2026-10-23T10:05:00Z",
57
81
  link: { state: "not_applicable", text: "not yet supported" },
@@ -59,7 +83,11 @@ export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
59
83
  skippedAlreadyHadReference: 0,
60
84
  lastRun: null,
61
85
  runs: [],
86
+ outsideRuns: [],
87
+ outsideNotice: null,
62
88
  },
63
89
  ],
64
90
  };
65
- FIXTURE_AUTOMATION_OVERVIEW.automations[0].lastRun = FIXTURE_AUTOMATION_OVERVIEW.automations[0].runs[2];
91
+ // Newest first, as the history is served.
92
+ FIXTURE_AUTOMATION_OVERVIEW.automations[0].runs.sort((left, right) => String(right.startedAt).localeCompare(String(left.startedAt)));
93
+ FIXTURE_AUTOMATION_OVERVIEW.automations[0].lastRun = FIXTURE_AUTOMATION_OVERVIEW.automations[0].runs[0];