@anthusai/papyrus 2.0.0-next.73 → 2.0.0-next.75
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.
- package/amplify/data/resource.js +69 -0
- package/amplify/data/resource.ts +73 -0
- package/amplify/data/schema.js +68 -0
- package/amplify/data/schema.ts +72 -0
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +25 -0
- package/amplify/functions/automation-view/schema.ts +27 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/plugins/reader-steering/config.js +3 -1
- package/amplify/plugins/reader-steering/config.ts +11 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +2 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +2 -0
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/newsroom-ops-overview.tsx +18 -0
- package/components/notification-center.tsx +262 -0
- package/components/topic-steering-workspace.tsx +17 -3
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/package.json +11 -1
- package/routes.manifest.json +1 -1
|
@@ -98,6 +98,14 @@ export type ReaderSteeringConfig = {
|
|
|
98
98
|
/** Most links (http, https or www) in a comment. */
|
|
99
99
|
maximumLinks: number;
|
|
100
100
|
};
|
|
101
|
+
proposals: {
|
|
102
|
+
/**
|
|
103
|
+
* Publish every proposal, in every state (drafts included), as public `_data/proposals/` files in each snapshot run.
|
|
104
|
+
* Off by default: proposals stay private records until a site opts in. Turning it off stops further updates; it does not
|
|
105
|
+
* delete files already published.
|
|
106
|
+
*/
|
|
107
|
+
publish: boolean;
|
|
108
|
+
};
|
|
101
109
|
/**
|
|
102
110
|
* The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
|
|
103
111
|
* Absent means no hype gap is published. The site names the dimensions in each group.
|
|
@@ -115,7 +123,7 @@ export type ReaderSteeringConfig = {
|
|
|
115
123
|
retirementPoll: boolean;
|
|
116
124
|
};
|
|
117
125
|
|
|
118
|
-
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
|
|
126
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
|
|
119
127
|
|
|
120
128
|
function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
121
129
|
if (raw === undefined) return {};
|
|
@@ -217,6 +225,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
217
225
|
if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
|
|
218
226
|
throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
|
|
219
227
|
}
|
|
228
|
+
const proposals = object(raw.proposals, "proposals", ["publish"]);
|
|
220
229
|
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
221
230
|
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
222
231
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
@@ -273,6 +282,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
273
282
|
maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
|
|
274
283
|
maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
|
|
275
284
|
},
|
|
285
|
+
proposals: { publish: flag(proposals.publish, "proposals.publish") },
|
|
276
286
|
retirementPoll,
|
|
277
287
|
...(hypeGap ? { hypeGap } : {}),
|
|
278
288
|
...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
|
|
@@ -159,6 +159,8 @@ export const readerSteeringPlugin = {
|
|
|
159
159
|
lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
|
|
160
160
|
lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
161
161
|
contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
|
|
162
|
+
// Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
|
|
163
|
+
contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
|
|
162
164
|
if (config.moderation.ai)
|
|
163
165
|
wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
|
|
164
166
|
lambda.addPermission("InvokeFromCmsCompute", {
|
|
@@ -182,6 +182,8 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
182
182
|
lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
|
|
183
183
|
lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
184
184
|
contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
|
|
185
|
+
// Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
|
|
186
|
+
contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
|
|
185
187
|
|
|
186
188
|
if (config.moderation.ai) wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
|
|
187
189
|
|
package/amplify/site-backend.js
CHANGED
|
@@ -22,6 +22,8 @@ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRe
|
|
|
22
22
|
import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
|
|
23
23
|
import { defineSiteData } from "./data/resource.js";
|
|
24
24
|
import { editorialChecks } from "./functions/editorial-checks/resource.js";
|
|
25
|
+
import { automationView } from "./functions/automation-view/resource.js";
|
|
26
|
+
import { wireAutomationView } from "./functions/automation-view/wire.js";
|
|
25
27
|
import { monitoringView } from "./functions/monitoring-view/resource.js";
|
|
26
28
|
import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
27
29
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
|
|
@@ -94,6 +96,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
94
96
|
const backend = defineBackend({
|
|
95
97
|
...pluginFunctions,
|
|
96
98
|
assignmentAction,
|
|
99
|
+
automationView,
|
|
97
100
|
auth: defineSiteAuth(authConfig),
|
|
98
101
|
categoryAction,
|
|
99
102
|
contentActions,
|
|
@@ -621,6 +624,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
621
624
|
});
|
|
622
625
|
wireEditorialChecks(backend);
|
|
623
626
|
wireMonitoringView(backend);
|
|
627
|
+
wireAutomationView(backend);
|
|
624
628
|
wirePlugins(backend, plugins, contentActionsLambda);
|
|
625
629
|
return backend;
|
|
626
630
|
}
|
package/amplify/site-backend.ts
CHANGED
|
@@ -29,6 +29,8 @@ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRe
|
|
|
29
29
|
import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
|
|
30
30
|
import { defineSiteData } from "./data/resource";
|
|
31
31
|
import { editorialChecks } from "./functions/editorial-checks/resource";
|
|
32
|
+
import { automationView } from "./functions/automation-view/resource";
|
|
33
|
+
import { wireAutomationView } from "./functions/automation-view/wire";
|
|
32
34
|
import { monitoringView } from "./functions/monitoring-view/resource";
|
|
33
35
|
import { wireMonitoringView } from "./functions/monitoring-view/wire";
|
|
34
36
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire";
|
|
@@ -115,6 +117,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
115
117
|
const backend = defineBackend({
|
|
116
118
|
...pluginFunctions,
|
|
117
119
|
assignmentAction,
|
|
120
|
+
automationView,
|
|
118
121
|
auth: defineSiteAuth(authConfig),
|
|
119
122
|
categoryAction,
|
|
120
123
|
contentActions,
|
|
@@ -766,6 +769,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
766
769
|
});
|
|
767
770
|
wireEditorialChecks(backend);
|
|
768
771
|
wireMonitoringView(backend);
|
|
772
|
+
wireAutomationView(backend);
|
|
769
773
|
wirePlugins(backend, plugins, contentActionsLambda);
|
|
770
774
|
|
|
771
775
|
return backend;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { loadAutomationOverview, runAutomationNow } from "../lib/automation-client";
|
|
5
|
+
import { FIXTURE_AUTOMATION_OVERVIEW } from "../lib/automation-fixture";
|
|
6
|
+
import type { AutomationResult } from "../lib/automation-overview";
|
|
7
|
+
import { AutomationView } from "./automation-view";
|
|
8
|
+
|
|
9
|
+
const REFRESH_MILLISECONDS = 60_000;
|
|
10
|
+
|
|
11
|
+
/** Administration, Automation: loads the records and their recent runs, and refreshes each minute. */
|
|
12
|
+
export function AutomationPanel({ isDemo }: { isDemo: boolean }) {
|
|
13
|
+
const [result, setResult] = useState<AutomationResult | null>(null);
|
|
14
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (isDemo) {
|
|
18
|
+
setResult(FIXTURE_AUTOMATION_OVERVIEW);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let live = true;
|
|
22
|
+
const refresh = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const next = await loadAutomationOverview();
|
|
25
|
+
if (live) setResult(next);
|
|
26
|
+
} catch {
|
|
27
|
+
if (live) setResult({ state: "unavailable" });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
void refresh();
|
|
31
|
+
const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
|
|
32
|
+
return () => {
|
|
33
|
+
live = false;
|
|
34
|
+
window.clearInterval(timer);
|
|
35
|
+
};
|
|
36
|
+
}, [isDemo]);
|
|
37
|
+
|
|
38
|
+
const runNow = isDemo ? undefined : async (automationId: string, input: Record<string, unknown> | null) => {
|
|
39
|
+
const outcome = await runAutomationNow(automationId, input);
|
|
40
|
+
try {
|
|
41
|
+
setResult(await loadAutomationOverview()); // show the new run without waiting for the minute
|
|
42
|
+
} catch {
|
|
43
|
+
/* the next refresh will */
|
|
44
|
+
}
|
|
45
|
+
return outcome;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return <AutomationView result={result} selectedId={selectedId} onSelect={setSelectedId} demo={isDemo} onRunNow={runNow} />;
|
|
49
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
atATimeText,
|
|
6
|
+
inputTemplateText,
|
|
7
|
+
parseRunInput,
|
|
8
|
+
runNowMessage,
|
|
9
|
+
type AutomationRecord,
|
|
10
|
+
type RunNowResult,
|
|
11
|
+
} from "../lib/automation-overview";
|
|
12
|
+
|
|
13
|
+
export type RunNowPhase = "idle" | "confirming" | "running" | "done";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Run now": a button, then a confirm step that says what will start and, when the record's input names
|
|
17
|
+
* placeholders for an item, asks for the input of this run. Starting costs money (the procedure calls a model),
|
|
18
|
+
* so nothing starts on the first click. `initialPhase` and `initialMessage` exist so every step renders from a test.
|
|
19
|
+
*/
|
|
20
|
+
export function RunNowControl({
|
|
21
|
+
record,
|
|
22
|
+
demo = false,
|
|
23
|
+
onRun,
|
|
24
|
+
initialPhase = "idle",
|
|
25
|
+
initialMessage = null,
|
|
26
|
+
}: {
|
|
27
|
+
record: Pick<AutomationRecord, "id" | "title" | "procedureKey" | "runNow" | "limits">;
|
|
28
|
+
demo?: boolean;
|
|
29
|
+
onRun?: (automationId: string, input: Record<string, unknown> | null) => Promise<RunNowResult>;
|
|
30
|
+
initialPhase?: RunNowPhase;
|
|
31
|
+
initialMessage?: string | null;
|
|
32
|
+
}) {
|
|
33
|
+
const [phase, setPhase] = useState<RunNowPhase>(initialPhase);
|
|
34
|
+
const [message, setMessage] = useState<string | null>(initialMessage);
|
|
35
|
+
const [text, setText] = useState(record.runNow.needsInput ? inputTemplateText(record.runNow.inputNames) : "");
|
|
36
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
if (!record.runNow.available) {
|
|
39
|
+
return (
|
|
40
|
+
<p className="news-desk-delegation-note" data-automation-run-now-unavailable>
|
|
41
|
+
Run now is not available: {record.runNow.reason === "disabled" ? "the automation is disabled. Enable it first." : (record.runNow.reason ?? "not supported")}.
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function confirm() {
|
|
47
|
+
const parsed = parseRunInput(text);
|
|
48
|
+
if (!parsed.ok) {
|
|
49
|
+
setProblem(parsed.message);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (record.runNow.needsInput && parsed.value === null) {
|
|
53
|
+
setProblem("This automation's input names an item, so give the input for this run.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setProblem(null);
|
|
57
|
+
setPhase("running");
|
|
58
|
+
try {
|
|
59
|
+
const result = onRun ? await onRun(record.id, parsed.value) : ({ state: "unavailable" } as RunNowResult);
|
|
60
|
+
setMessage(runNowMessage(result));
|
|
61
|
+
} catch {
|
|
62
|
+
setMessage(runNowMessage({ state: "unavailable" }));
|
|
63
|
+
}
|
|
64
|
+
setPhase("done");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div data-automation-run-now={record.id} data-run-now-phase={phase}>
|
|
69
|
+
{phase === "idle" || phase === "done" ? (
|
|
70
|
+
<button type="button" data-automation-run-now-start onClick={() => { setMessage(null); setPhase("confirming"); }}>
|
|
71
|
+
Run now
|
|
72
|
+
</button>
|
|
73
|
+
) : null}
|
|
74
|
+
{phase === "confirming" || phase === "running" ? (
|
|
75
|
+
<div role="group" aria-label={`Confirm Run now for ${record.title}`} data-automation-run-now-confirm>
|
|
76
|
+
<p>
|
|
77
|
+
Run <strong>{record.procedureKey}</strong> now? It calls a model, which costs money, and it counts against the
|
|
78
|
+
limit ({atATimeText(record.limits)}). The run is linked to this automation.
|
|
79
|
+
</p>
|
|
80
|
+
{demo ? <p className="news-desk-delegation-note">This is the demo, so nothing will start.</p> : null}
|
|
81
|
+
<label>
|
|
82
|
+
Input for this run{record.runNow.needsInput ? " (required)" : " (optional; blank uses the record's input)"}
|
|
83
|
+
<textarea rows={4} value={text} onChange={(event) => setText(event.target.value)} disabled={phase === "running"} data-automation-run-now-input />
|
|
84
|
+
</label>
|
|
85
|
+
{problem ? <p role="alert" data-automation-run-now-problem>{problem}</p> : null}
|
|
86
|
+
<button type="button" onClick={() => void confirm()} disabled={phase === "running" || demo} data-automation-run-now-confirm-button>
|
|
87
|
+
{phase === "running" ? "Starting…" : "Confirm: start the run"}
|
|
88
|
+
</button>{" "}
|
|
89
|
+
<button type="button" onClick={() => { setPhase("idle"); setProblem(null); }} disabled={phase === "running"} data-automation-run-now-cancel>
|
|
90
|
+
Cancel
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
) : null}
|
|
94
|
+
{message ? <p role="status" data-automation-run-now-message>{message}</p> : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { RunNowControl } from "./automation-run-now";
|
|
3
|
+
import {
|
|
4
|
+
ENABLED_NOTE,
|
|
5
|
+
OUTSIDE_NOTICE,
|
|
6
|
+
atATimeText,
|
|
7
|
+
costText,
|
|
8
|
+
durationText,
|
|
9
|
+
enabledText,
|
|
10
|
+
formatUtc,
|
|
11
|
+
linkText,
|
|
12
|
+
noticeText,
|
|
13
|
+
proceduresHref,
|
|
14
|
+
statusMark,
|
|
15
|
+
statusText,
|
|
16
|
+
storyHref,
|
|
17
|
+
triggerText,
|
|
18
|
+
whoText,
|
|
19
|
+
type AutomationOutsideRun,
|
|
20
|
+
type AutomationRecord,
|
|
21
|
+
type AutomationResult,
|
|
22
|
+
type AutomationRun,
|
|
23
|
+
type RunNowResult,
|
|
24
|
+
} from "../lib/automation-overview";
|
|
25
|
+
|
|
26
|
+
function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="category-steering-empty" role={kind === "loading" ? "status" : "alert"} data-automation-state={kind}>
|
|
29
|
+
<strong>{title}</strong>
|
|
30
|
+
{children ? <p>{children}</p> : null}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function RunLine({ run, demo }: { run: AutomationRun; demo: boolean }) {
|
|
36
|
+
return (
|
|
37
|
+
<li data-automation-run={run.runId ?? run.sourceId ?? "unlinked"} data-run-status={run.status ?? "unknown"}>
|
|
38
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
39
|
+
<span><span aria-hidden="true">{statusMark(run.status)} </span>Status: {statusText(run.status)} · {durationText(run.durationSeconds)}</span>
|
|
40
|
+
<span>{whoText(run)}</span>
|
|
41
|
+
{run.reason ? <span data-automation-run-reason>Reason: {run.reason}</span> : null}
|
|
42
|
+
{run.error ? <span data-automation-run-error>Error: {run.error}</span> : null}
|
|
43
|
+
<span>
|
|
44
|
+
{run.runId ? <>Run {run.runId} (<Link href={proceduresHref(demo)}>Procedures</Link>)</> : run.triggerStatus === "started" ? "Run record not linked yet" : "No run record"}
|
|
45
|
+
{run.runRecord === "missing" ? " (the run record was not found)" : ""}
|
|
46
|
+
{run.runRecord === "unavailable" ? " (the run record could not be read)" : ""}
|
|
47
|
+
</span>
|
|
48
|
+
<span>
|
|
49
|
+
{run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)} data-automation-story-link>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
|
|
50
|
+
</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>
|
|
54
|
+
</li>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
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)} · {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 }) {
|
|
75
|
+
const notice = noticeText(record.notice);
|
|
76
|
+
return (
|
|
77
|
+
<article className="news-desk-doctrine-card" data-automation-detail={record.id}>
|
|
78
|
+
<div className="news-desk-doctrine-card__header"><div><h3>{record.title}</h3><span>{record.description ?? record.id}</span></div></div>
|
|
79
|
+
<dl className="news-desk-monitoring__usage">
|
|
80
|
+
<div><dt>Trigger</dt><dd>{triggerText(record.trigger)}{record.supported ? "" : ` (${record.supportNote ?? "not yet supported"})`}</dd></div>
|
|
81
|
+
<div><dt>Procedure</dt><dd>{record.procedureKey}</dd></div>
|
|
82
|
+
<div><dt>Input</dt><dd>{Object.keys(record.inputTemplate).length ? Object.entries(record.inputTemplate).map(([name, template]) => `${name} = ${template}`).join("; ") : "none"}</dd></div>
|
|
83
|
+
<div><dt>Enabled</dt><dd>{enabledText(record)} ({ENABLED_NOTE})</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>
|
|
85
|
+
<div><dt>Router</dt><dd data-automation-link={record.link?.state ?? "unchecked"}>{linkText(record.link)}</dd></div>
|
|
86
|
+
</dl>
|
|
87
|
+
{notice ? <p className="news-desk-delegation-note" data-automation-notice>{notice}</p> : null}
|
|
88
|
+
<RunNowControl record={record} demo={demo} onRun={onRunNow} />
|
|
89
|
+
<h4>Recent runs</h4>
|
|
90
|
+
{record.runs.length ? (
|
|
91
|
+
<ul className="news-desk-monitoring__list" data-automation-runs>
|
|
92
|
+
{record.runs.map((run) => <RunLine key={run.runId ?? `${run.sourceId}-${run.startedAt}`} run={run} demo={demo} />)}
|
|
93
|
+
</ul>
|
|
94
|
+
) : <p data-automation-no-runs>No runs yet.</p>}
|
|
95
|
+
{record.skippedAlreadyHadReference > 0 ? (
|
|
96
|
+
<p className="news-desk-delegation-note" data-automation-skipped>
|
|
97
|
+
{record.skippedAlreadyHadReference} {record.skippedAlreadyHadReference === 1 ? "item was" : "items were"} not started because the Reference already existed.
|
|
98
|
+
</p>
|
|
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}
|
|
112
|
+
</article>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ListItem({ record, selected, onSelect }: { record: AutomationRecord; selected: boolean; onSelect: (id: string) => void }) {
|
|
117
|
+
const last = record.lastRun;
|
|
118
|
+
return (
|
|
119
|
+
<li data-automation={record.id}>
|
|
120
|
+
<div>
|
|
121
|
+
<strong>{record.title}</strong>
|
|
122
|
+
<span>{triggerText(record.trigger)}{record.supported ? "" : ` (${record.supportNote ?? "not yet supported"})`}</span>
|
|
123
|
+
</div>
|
|
124
|
+
<span>Procedure: {record.procedureKey}</span>
|
|
125
|
+
<span>{enabledText(record)} ({ENABLED_NOTE})</span>
|
|
126
|
+
<span data-automation-last-run>
|
|
127
|
+
Last run: <span aria-hidden="true">{statusMark(last?.status)} </span>{statusText(last?.status)}{last ? `, ${formatUtc(last.startedAt)}` : ""}
|
|
128
|
+
</span>
|
|
129
|
+
<button type="button" aria-pressed={selected} data-automation-select={record.id} onClick={() => onSelect(record.id)}>
|
|
130
|
+
{selected ? "Showing runs" : "Show runs"}
|
|
131
|
+
</button>
|
|
132
|
+
</li>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
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
|
+
|
|
139
|
+
export function AutomationView({
|
|
140
|
+
result,
|
|
141
|
+
selectedId,
|
|
142
|
+
onSelect,
|
|
143
|
+
demo = false,
|
|
144
|
+
onRunNow,
|
|
145
|
+
}: {
|
|
146
|
+
result: AutomationResult | null;
|
|
147
|
+
selectedId: string | null;
|
|
148
|
+
onSelect: (id: string) => void;
|
|
149
|
+
demo?: boolean;
|
|
150
|
+
onRunNow?: RunNowHandler;
|
|
151
|
+
}) {
|
|
152
|
+
let body: React.ReactNode;
|
|
153
|
+
if (result === null) {
|
|
154
|
+
body = <Message kind="loading" title="Loading automations" />;
|
|
155
|
+
} else if ("error" in result) {
|
|
156
|
+
body = result.error === "forbidden"
|
|
157
|
+
? <Message kind="forbidden" title="Automations are for editors and admins">Your account does not have access to this page. Ask an admin for the editor role.</Message>
|
|
158
|
+
: <Message kind="error" title="Automations could not be read">The request was not understood. Reload the page.</Message>;
|
|
159
|
+
} else if (result.state === "unavailable") {
|
|
160
|
+
body = <Message kind="unavailable" title="Automations could not be read right now">Nothing is affected. Try again in a minute.</Message>;
|
|
161
|
+
} else if (result.state === "empty") {
|
|
162
|
+
body = <Message kind="empty" title="No automations yet">An operator creates one with <code>papyrus automations create</code>. Nothing is configured to run on its own from this page.</Message>;
|
|
163
|
+
} else {
|
|
164
|
+
const selected = result.automations.find((record) => record.id === selectedId) ?? (result.automations.length === 1 ? result.automations[0] : null);
|
|
165
|
+
body = (
|
|
166
|
+
<>
|
|
167
|
+
<article className="news-desk-doctrine-card" data-automation-section="list">
|
|
168
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Automations</h3><span>What starts a procedure by itself</span></div></div>
|
|
169
|
+
<ul className="news-desk-monitoring__list">
|
|
170
|
+
{result.automations.map((record) => <ListItem key={record.id} record={record} selected={selected?.id === record.id} onSelect={onSelect} />)}
|
|
171
|
+
</ul>
|
|
172
|
+
{result.monitoring === "not_configured" ? <p className="news-desk-delegation-note" data-automation-notice>{noticeText("monitoring_not_configured")}</p> : null}
|
|
173
|
+
</article>
|
|
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>}
|
|
175
|
+
</>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return (
|
|
179
|
+
<div data-news-desk-admin-panel="automations">
|
|
180
|
+
<article className="news-desk-doctrine-card">
|
|
181
|
+
<div className="news-desk-doctrine-card__header">
|
|
182
|
+
<div>
|
|
183
|
+
<h3>Automation</h3>
|
|
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>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
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}
|
|
188
|
+
</article>
|
|
189
|
+
{body}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -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">
|