@anthusai/papyrus 5.31.0 → 5.33.0

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 (31) hide show
  1. package/amplify/data/resource.js +69 -0
  2. package/amplify/data/resource.ts +73 -0
  3. package/amplify/data/schema.js +68 -0
  4. package/amplify/data/schema.ts +72 -0
  5. package/amplify/functions/automation-view/handler.py +17 -0
  6. package/amplify/functions/automation-view/resource.js +24 -0
  7. package/amplify/functions/automation-view/resource.ts +34 -0
  8. package/amplify/functions/automation-view/schema.js +25 -0
  9. package/amplify/functions/automation-view/schema.ts +27 -0
  10. package/amplify/functions/automation-view/wire.js +56 -0
  11. package/amplify/functions/automation-view/wire.ts +76 -0
  12. package/amplify/plugins/reader-steering/config.js +3 -1
  13. package/amplify/plugins/reader-steering/config.ts +11 -1
  14. package/amplify/plugins/reader-steering/papyrus.plugin.js +2 -0
  15. package/amplify/plugins/reader-steering/papyrus.plugin.ts +2 -0
  16. package/amplify/site-backend.js +4 -0
  17. package/amplify/site-backend.ts +4 -0
  18. package/components/automation-panel.tsx +49 -0
  19. package/components/automation-run-now.tsx +97 -0
  20. package/components/automation-view.tsx +192 -0
  21. package/components/newsroom-ops-overview.tsx +18 -0
  22. package/components/notification-center.tsx +262 -0
  23. package/components/topic-steering-workspace.tsx +17 -3
  24. package/lib/automation-client.ts +54 -0
  25. package/lib/automation-fixture.ts +93 -0
  26. package/lib/automation-overview.ts +232 -0
  27. package/lib/review-client.ts +99 -0
  28. package/lib/review-fixture.ts +45 -0
  29. package/lib/review-requests.ts +238 -0
  30. package/package.json +11 -1
  31. package/routes.manifest.json +1 -1
@@ -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
+ }
@@ -14,6 +14,7 @@ import type { ReactNode, RefObject } from "react";
14
14
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useTransition } from "react";
15
15
  import amplifyOutputs from "papyrus-amplify-outputs";
16
16
  import { EditorialChecksAdminPanel } from "./editorial-checks-admin-panel";
17
+ import { AutomationPanel } from "./automation-panel";
17
18
  import { MonitoringPanel } from "./monitoring-panel";
18
19
  import { ModerationPanel } from "./moderation-panel";
19
20
  import { NewsletterPanel } from "./newsletter-panel";
@@ -251,7 +252,7 @@ type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" |
251
252
  type ReportingPacketReviewDecision = "select" | "merge" | "brief" | "hold" | "kill";
252
253
  type AssignmentDeskViewMode = "queue" | "budget";
253
254
  type UserRoleAction = "grant" | "revoke";
254
- type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
255
+ type AdministrationPanel = "users" | "policies" | "sections" | "procedures" | "automations" | "delegations" | "signups" | "newsletter" | "moderation" | "editorial-checks" | "monitoring";
255
256
  export type NewsDeskTab = "overview" | "desks" | "topics" | "concepts" | "references" | "messages" | "assignments" | "administration" | "search";
256
257
  type LexicalRuleScope = "publication" | "corpus" | "classifier" | "category";
257
258
  type AnalysisReindexMode = "online-update" | "classifier-retrain" | "scoped-topic-rebuild" | "entity-graph-rebuild" | "generated-analysis-rebuild";
@@ -3003,7 +3004,7 @@ function NewsDeskDashboard({
3003
3004
  return entries;
3004
3005
  }, [summaryStatus, tabCounts]);
3005
3006
  const opsPageTitle = activeTab === "overview"
3006
- ? "Overview"
3007
+ ? "Notification Center"
3007
3008
  : activeTab === "administration" && administrationPanel === "sections"
3008
3009
  ? "Sections"
3009
3010
  : formatDeskSectionHeadline(activeTab);
@@ -3061,8 +3062,11 @@ function NewsDeskDashboard({
3061
3062
 
3062
3063
  {activeTab === "overview" ? (
3063
3064
  <NewsroomOpsOverview
3065
+ actorLabel={authState.label}
3064
3066
  assignments={assignments}
3067
+ decisions={relevanceDecisions}
3065
3068
  demo={Boolean(dashboard.isDemo)}
3069
+ humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
3066
3070
  messages={messages}
3067
3071
  newsroomSections={newsroomSections}
3068
3072
  references={references}
@@ -4315,6 +4319,15 @@ function AdministrationDeskView({
4315
4319
  <strong>Procedures</strong>
4316
4320
  <span>Procedure registry and versioned Tactus code</span>
4317
4321
  </Link>
4322
+ <Link
4323
+ href={administrationPanelHref("automations", isDemo)}
4324
+ data-news-desk-admin-nav="automations"
4325
+ data-active={administrationPanel === "automations" || undefined}
4326
+ onClick={() => onPanelChange("automations")}
4327
+ >
4328
+ <strong>Automation</strong>
4329
+ <span>Procedures that start on their own</span>
4330
+ </Link>
4318
4331
  <Link
4319
4332
  href={administrationPanelHref("delegations", isDemo)}
4320
4333
  data-news-desk-admin-nav="delegations"
@@ -4381,6 +4394,7 @@ function AdministrationDeskView({
4381
4394
  {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4382
4395
  {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4383
4396
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4397
+ {administrationPanel === "automations" ? <AutomationPanel isDemo={isDemo} /> : null}
4384
4398
  {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4385
4399
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
4386
4400
  {administrationPanel === "users" ? (
@@ -12855,7 +12869,7 @@ function readerPluginEnabled(): boolean {
12855
12869
  }
12856
12870
 
12857
12871
  function normalizeAdministrationPanel(value: string | null | undefined, demo = false): AdministrationPanel {
12858
- if (value === "policies" || value === "sections" || value === "procedures" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12872
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12859
12873
  if (value === "signups" && signupPluginEnabled()) return value;
12860
12874
  if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12861
12875
  if (value === "moderation" && readerPluginEnabled()) return value;
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { AutomationResult, RunNowResult } from "./automation-overview";
5
+
6
+ type GraphQLResult = { data?: { automationOverview?: unknown }; errors?: { message?: string; errorType?: string }[] };
7
+
8
+ /**
9
+ * Reads the automations through the site's own authenticated API (user pool, editor or admin).
10
+ * The browser holds no table name, key or secret: the function behind the query does the reads.
11
+ */
12
+ export async function loadAutomationOverview(): Promise<AutomationResult> {
13
+ const client = generateClient({ authMode: "userPool" });
14
+ let result: GraphQLResult;
15
+ try {
16
+ result = (await client.graphql({
17
+ query: `query AutomationOverview($automationId: String) { automationOverview(automationId: $automationId) }`,
18
+ variables: {},
19
+ })) as GraphQLResult;
20
+ } catch (error) {
21
+ // Amplify throws the response when AppSync returns errors.
22
+ result = (error && typeof error === "object" && "errors" in error ? error : { errors: [{ message: "failed" }] }) as GraphQLResult;
23
+ }
24
+ const refused = result.errors?.some((entry) => /unauthorized|not authorized|forbidden/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`));
25
+ if (refused) return { error: "forbidden" };
26
+ if (result.errors?.length || result.data?.automationOverview === undefined || result.data.automationOverview === null) return { state: "unavailable" };
27
+ const raw = result.data.automationOverview;
28
+ return (typeof raw === "string" ? JSON.parse(raw) : raw) as AutomationResult;
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
+ }
@@ -0,0 +1,93 @@
1
+ import type { AutomationOverview, AutomationRun } from "./automation-overview";
2
+
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
+ }
13
+
14
+ /** A realistic overview for tests and the `?demo=1` newsroom: one live monitor automation and one stored schedule. */
15
+ export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
16
+ state: "ready",
17
+ asOf: "2026-10-23T12:00:00Z",
18
+ monitoring: "ready",
19
+ automations: [
20
+ {
21
+ id: "example.ttab.ruling-analysis",
22
+ title: "Analyze new TTAB rulings",
23
+ description: "Drafts a story for each new Board decision.",
24
+ enabled: true,
25
+ enabledNote: "the router starts nothing for a disabled automation",
26
+ trigger: { kind: "monitor", config: { monitorId: "ttab", publicationId: "example" } },
27
+ supported: true,
28
+ supportNote: null,
29
+ procedureKey: "example.ttab.ruling-analysis",
30
+ inputTemplate: { proceeding: "{sourceIdPrefix}", opinion_url: "{documentUrl}" },
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"] },
34
+ createdAt: "2026-10-23T10:00:00Z",
35
+ updatedAt: "2026-10-23T10:00:00Z",
36
+ link: { state: "linked", text: "The router's trigger matches this record.", routerMode: "live", routerEnabled: true },
37
+ notice: null,
38
+ skippedAlreadyHadReference: 2,
39
+ lastRun: null,
40
+ runs: [
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 },
62
+ ],
63
+ outsideNotice: null,
64
+ },
65
+ {
66
+ id: "example.weekly-digest",
67
+ title: "Weekly digest",
68
+ description: null,
69
+ enabled: false,
70
+ enabledNote: "the router starts nothing for a disabled automation",
71
+ trigger: { kind: "schedule", config: { cron: "0 9 * * 1" } },
72
+ supported: false,
73
+ supportNote: "not yet supported",
74
+ procedureKey: "example.weekly-digest",
75
+ inputTemplate: {},
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: [] },
79
+ createdAt: "2026-10-23T10:05:00Z",
80
+ updatedAt: "2026-10-23T10:05:00Z",
81
+ link: { state: "not_applicable", text: "not yet supported" },
82
+ notice: "not yet supported",
83
+ skippedAlreadyHadReference: 0,
84
+ lastRun: null,
85
+ runs: [],
86
+ outsideRuns: [],
87
+ outsideNotice: null,
88
+ },
89
+ ],
90
+ };
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];