@anthusai/papyrus 5.31.0 → 5.32.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.
@@ -1163,6 +1163,25 @@ const schema = a.schema({
1163
1163
  allow.group("editor").to(["read"]),
1164
1164
  allow.group(adminGroup).to(authoringOperations),
1165
1165
  ]),
1166
+ Automation: a
1167
+ .model({
1168
+ id: a.id().required(),
1169
+ title: a.string().required(),
1170
+ description: a.string(),
1171
+ enabled: a.boolean().required(),
1172
+ trigger: a.json().required(),
1173
+ procedureKey: a.string().required(),
1174
+ inputTemplate: a.json(),
1175
+ limits: a.json(),
1176
+ createdBy: a.string(),
1177
+ createdAt: a.datetime().required(),
1178
+ updatedBy: a.string(),
1179
+ updatedAt: a.datetime().required(),
1180
+ })
1181
+ .authorization((allow) => [
1182
+ allow.group("editor").to(["read", "create", "update"]),
1183
+ allow.group(adminGroup).to(authoringOperations),
1184
+ ]),
1166
1185
  NewsroomSection: a
1167
1186
  .model({
1168
1187
  id: a.id().required(),
@@ -1259,6 +1259,26 @@ const schema = a.schema({
1259
1259
  allow.group(adminGroup).to(authoringOperations),
1260
1260
  ]),
1261
1261
 
1262
+ Automation: a
1263
+ .model({
1264
+ id: a.id().required(),
1265
+ title: a.string().required(),
1266
+ description: a.string(),
1267
+ enabled: a.boolean().required(),
1268
+ trigger: a.json().required(),
1269
+ procedureKey: a.string().required(),
1270
+ inputTemplate: a.json(),
1271
+ limits: a.json(),
1272
+ createdBy: a.string(),
1273
+ createdAt: a.datetime().required(),
1274
+ updatedBy: a.string(),
1275
+ updatedAt: a.datetime().required(),
1276
+ })
1277
+ .authorization((allow) => [
1278
+ allow.group("editor").to(["read", "create", "update"]),
1279
+ allow.group(adminGroup).to(authoringOperations),
1280
+ ]),
1281
+
1262
1282
  NewsroomSection: a
1263
1283
  .model({
1264
1284
  id: a.id().required(),
@@ -2,6 +2,7 @@ import { a } from "@aws-amplify/backend";
2
2
  import { assignmentAction } from "../functions/assignment-action/resource.js";
3
3
  import { categoryAction } from "../functions/category-action/resource.js";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema.js";
5
+ import { automationViewSchemaDefinitions } from "../functions/automation-view/schema.js";
5
6
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema.js";
6
7
  import { contentActions } from "../functions/content-actions/resource.js";
7
8
  import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
@@ -1082,6 +1083,25 @@ function buildBaseDefinitions(includeEditions) {
1082
1083
  allow.group("editor").to(["read"]),
1083
1084
  allow.group(adminGroup).to(authoringOperations),
1084
1085
  ]),
1086
+ /** A named "when X happens, run procedure Y" (PPY-9ff95b). Step 1 mirrors the monitoring router's procedureTriggers and starts nothing itself. */
1087
+ Automation: modelWithoutSubscriptions({
1088
+ id: a.id().required(),
1089
+ title: a.string().required(),
1090
+ description: a.string(),
1091
+ enabled: a.boolean().required(),
1092
+ trigger: a.json().required(),
1093
+ procedureKey: a.string().required(),
1094
+ inputTemplate: a.json(),
1095
+ limits: a.json(),
1096
+ createdBy: a.string(),
1097
+ createdAt: a.datetime().required(),
1098
+ updatedBy: a.string(),
1099
+ updatedAt: a.datetime().required(),
1100
+ })
1101
+ .authorization((allow) => [
1102
+ allow.group("editor").to(["read", "create", "update"]),
1103
+ allow.group(adminGroup).to(authoringOperations),
1104
+ ]),
1085
1105
  NewsroomSection: modelWithoutSubscriptions({
1086
1106
  id: a.id().required(),
1087
1107
  tenantId: a.string(),
@@ -2021,6 +2041,7 @@ function buildBaseDefinitions(includeEditions) {
2021
2041
  ]),
2022
2042
  ...editorialChecksSchemaDefinitions,
2023
2043
  ...monitoringViewSchemaDefinitions,
2044
+ ...automationViewSchemaDefinitions,
2024
2045
  };
2025
2046
  if (!includeEditions) {
2026
2047
  const mutableDefinitions = definitions;
@@ -2,6 +2,7 @@ import { type ClientSchema, a } from "@aws-amplify/backend";
2
2
  import { assignmentAction } from "../functions/assignment-action/resource";
3
3
  import { categoryAction } from "../functions/category-action/resource";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
5
+ import { automationViewSchemaDefinitions } from "../functions/automation-view/schema";
5
6
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
6
7
  import { contentActions } from "../functions/content-actions/resource";
7
8
  import { knowledgeQuery } from "../functions/knowledge-query/resource";
@@ -1176,6 +1177,26 @@ const definitions = {
1176
1177
  allow.group(adminGroup).to(authoringOperations),
1177
1178
  ]),
1178
1179
 
1180
+ /** A named "when X happens, run procedure Y" (PPY-9ff95b). Step 1 mirrors the monitoring router's procedureTriggers and starts nothing itself. */
1181
+ Automation: modelWithoutSubscriptions({
1182
+ id: a.id().required(),
1183
+ title: a.string().required(),
1184
+ description: a.string(),
1185
+ enabled: a.boolean().required(),
1186
+ trigger: a.json().required(),
1187
+ procedureKey: a.string().required(),
1188
+ inputTemplate: a.json(),
1189
+ limits: a.json(),
1190
+ createdBy: a.string(),
1191
+ createdAt: a.datetime().required(),
1192
+ updatedBy: a.string(),
1193
+ updatedAt: a.datetime().required(),
1194
+ })
1195
+ .authorization((allow) => [
1196
+ allow.group("editor").to(["read", "create", "update"]),
1197
+ allow.group(adminGroup).to(authoringOperations),
1198
+ ]),
1199
+
1179
1200
  NewsroomSection: modelWithoutSubscriptions({
1180
1201
  id: a.id().required(),
1181
1202
  tenantId: a.string(),
@@ -2145,6 +2166,7 @@ const definitions = {
2145
2166
  ]),
2146
2167
  ...editorialChecksSchemaDefinitions,
2147
2168
  ...monitoringViewSchemaDefinitions,
2169
+ ...automationViewSchemaDefinitions,
2148
2170
  };
2149
2171
  if (!includeEditions) {
2150
2172
  const mutableDefinitions = definitions as Record<string, unknown>;
@@ -0,0 +1,11 @@
1
+ """The automation-view function: `automationOverview` for editors and admins, read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from papyrus_content.automation_view import handle
8
+
9
+
10
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
11
+ return handle(event)
@@ -0,0 +1,24 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import { pythonLambdaCode } from "../shared/python-bundle.js";
5
+ /** AppSync gives a resolver 30 seconds; a table read and ten run lookups need a few. */
6
+ export const AUTOMATION_VIEW_TIMEOUT_SECONDS = 25;
7
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
8
+ export const automationView = defineFunction((scope) => new Function(scope, "papyrus-automation-view", {
9
+ runtime: Runtime.PYTHON_3_12,
10
+ architecture: Architecture.ARM_64,
11
+ handler: "handler.handler",
12
+ timeout: Duration.seconds(AUTOMATION_VIEW_TIMEOUT_SECONDS),
13
+ memorySize: 256,
14
+ code: pythonLambdaCode({ functionDir: "automation-view", modules: ["papyrus_content"], noDeps: true }, [
15
+ "bash",
16
+ "-c",
17
+ [
18
+ "set -euo pipefail",
19
+ "mkdir -p /asset-output/papyrus_content",
20
+ "cp amplify/functions/automation-view/handler.py /asset-output/handler.py",
21
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
22
+ ].join(" && "),
23
+ ]),
24
+ }), { resourceGroupName: "data" });
@@ -0,0 +1,34 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import type { Construct } from "constructs";
5
+ import { pythonLambdaCode } from "../shared/python-bundle";
6
+
7
+ /** AppSync gives a resolver 30 seconds; a table read and ten run lookups need a few. */
8
+ export const AUTOMATION_VIEW_TIMEOUT_SECONDS = 25;
9
+
10
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
11
+ export const automationView = defineFunction(
12
+ (scope: Construct) =>
13
+ new Function(scope, "papyrus-automation-view", {
14
+ runtime: Runtime.PYTHON_3_12,
15
+ architecture: Architecture.ARM_64,
16
+ handler: "handler.handler",
17
+ timeout: Duration.seconds(AUTOMATION_VIEW_TIMEOUT_SECONDS),
18
+ memorySize: 256,
19
+ code: pythonLambdaCode(
20
+ { functionDir: "automation-view", modules: ["papyrus_content"], noDeps: true },
21
+ [
22
+ "bash",
23
+ "-c",
24
+ [
25
+ "set -euo pipefail",
26
+ "mkdir -p /asset-output/papyrus_content",
27
+ "cp amplify/functions/automation-view/handler.py /asset-output/handler.py",
28
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
29
+ ].join(" && "),
30
+ ],
31
+ ),
32
+ }),
33
+ { resourceGroupName: "data" },
34
+ );
@@ -0,0 +1,16 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { automationView } from "./resource.js";
3
+ const editors = ["editor", "admin"];
4
+ /**
5
+ * Core to every site's data API. The Automation page reads the automation records, the monitoring
6
+ * trigger rows they link to, and the procedure runs through this one query. AppSync admits editors
7
+ * and admins only; the function checks the groups again.
8
+ */
9
+ export const automationViewSchemaDefinitions = {
10
+ automationOverview: a
11
+ .query()
12
+ .arguments({ automationId: a.string() })
13
+ .returns(a.json())
14
+ .authorization((allow) => [allow.groups(editors)])
15
+ .handler(a.handler.function(automationView)),
16
+ };
@@ -0,0 +1,18 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { automationView } from "./resource";
3
+
4
+ const editors = ["editor", "admin"];
5
+
6
+ /**
7
+ * Core to every site's data API. The Automation page reads the automation records, the monitoring
8
+ * trigger rows they link to, and the procedure runs through this one query. AppSync admits editors
9
+ * and admins only; the function checks the groups again.
10
+ */
11
+ export const automationViewSchemaDefinitions = {
12
+ automationOverview: a
13
+ .query()
14
+ .arguments({ automationId: a.string() })
15
+ .returns(a.json())
16
+ .authorization((allow) => [allow.groups(editors)])
17
+ .handler(a.handler.function(automationView)),
18
+ };
@@ -0,0 +1,32 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import { MONITORING_TABLE_PARAMETER, MONITORING_VIEW_TABLE_ACTIONS, monitoringViewParameterArn, monitoringViewTableArns, } from "../monitoring-view/wire.js";
4
+ /** The two reads the function makes of its own site's API with its own role. It writes nothing. */
5
+ export const AUTOMATION_VIEW_FUNCTION_FIELDS = ["Query/fields/listAutomations", "Query/fields/getProcedureRun"];
6
+ /**
7
+ * Environment and least-privilege IAM for the automation-view function: two fields of the site's own API,
8
+ * and the same read-only grant on the monitoring table that the Monitoring page has. A site outside the
9
+ * monitoring account finds no parameter and the page lists its records without runs.
10
+ */
11
+ export function wireAutomationView(backend) {
12
+ const lambda = backend.automationView.resources.lambda;
13
+ const stack = Stack.of(lambda);
14
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
15
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
16
+ const apiId = backend.data.resources.graphqlApi.apiId;
17
+ lambda.addToRolePolicy(new PolicyStatement({
18
+ effect: Effect.ALLOW,
19
+ actions: ["appsync:GraphQL"],
20
+ resources: AUTOMATION_VIEW_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
21
+ }));
22
+ lambda.addToRolePolicy(new PolicyStatement({
23
+ effect: Effect.ALLOW,
24
+ actions: ["ssm:GetParameter"],
25
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
26
+ }));
27
+ lambda.addToRolePolicy(new PolicyStatement({
28
+ effect: Effect.ALLOW,
29
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
30
+ resources: monitoringViewTableArns(stack.region, stack.account),
31
+ }));
32
+ }
@@ -0,0 +1,46 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
4
+ import {
5
+ MONITORING_TABLE_PARAMETER,
6
+ MONITORING_VIEW_TABLE_ACTIONS,
7
+ monitoringViewParameterArn,
8
+ monitoringViewTableArns,
9
+ } from "../monitoring-view/wire";
10
+
11
+ /** The two reads the function makes of its own site's API with its own role. It writes nothing. */
12
+ export const AUTOMATION_VIEW_FUNCTION_FIELDS = ["Query/fields/listAutomations", "Query/fields/getProcedureRun"];
13
+
14
+ /**
15
+ * Environment and least-privilege IAM for the automation-view function: two fields of the site's own API,
16
+ * and the same read-only grant on the monitoring table that the Monitoring page has. A site outside the
17
+ * monitoring account finds no parameter and the page lists its records without runs.
18
+ */
19
+ export function wireAutomationView(backend: any): void {
20
+ const lambda = backend.automationView.resources.lambda as LambdaFunction;
21
+ const stack = Stack.of(lambda);
22
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
23
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
24
+ const apiId = backend.data.resources.graphqlApi.apiId as string;
25
+ lambda.addToRolePolicy(
26
+ new PolicyStatement({
27
+ effect: Effect.ALLOW,
28
+ actions: ["appsync:GraphQL"],
29
+ resources: AUTOMATION_VIEW_FUNCTION_FIELDS.map((field) => `arn:aws:appsync:${stack.region}:${stack.account}:apis/${apiId}/types/${field}`),
30
+ }),
31
+ );
32
+ lambda.addToRolePolicy(
33
+ new PolicyStatement({
34
+ effect: Effect.ALLOW,
35
+ actions: ["ssm:GetParameter"],
36
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
37
+ }),
38
+ );
39
+ lambda.addToRolePolicy(
40
+ new PolicyStatement({
41
+ effect: Effect.ALLOW,
42
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
43
+ resources: monitoringViewTableArns(stack.region, stack.account),
44
+ }),
45
+ );
46
+ }
@@ -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
  }
@@ -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,39 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { loadAutomationOverview } 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
+ return <AutomationView result={result} selectedId={selectedId} onSelect={setSelectedId} demo={isDemo} />;
39
+ }
@@ -0,0 +1,149 @@
1
+ import Link from "next/link";
2
+ import {
3
+ ENABLED_NOTE,
4
+ durationText,
5
+ enabledText,
6
+ formatUtc,
7
+ linkText,
8
+ noticeText,
9
+ proceduresHref,
10
+ statusMark,
11
+ statusText,
12
+ storyHref,
13
+ triggerText,
14
+ type AutomationRecord,
15
+ type AutomationResult,
16
+ type AutomationRun,
17
+ } from "../lib/automation-overview";
18
+
19
+ function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
20
+ return (
21
+ <div className="category-steering-empty" role={kind === "loading" ? "status" : "alert"} data-automation-state={kind}>
22
+ <strong>{title}</strong>
23
+ {children ? <p>{children}</p> : null}
24
+ </div>
25
+ );
26
+ }
27
+
28
+ function RunLine({ run, demo }: { run: AutomationRun; demo: boolean }) {
29
+ return (
30
+ <li data-automation-run={run.runId ?? run.sourceId ?? "unlinked"} data-run-status={run.status ?? "unknown"}>
31
+ <strong>{formatUtc(run.startedAt)}</strong>
32
+ <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>
34
+ {run.error ? <span data-automation-run-error>Error: {run.error}</span> : null}
35
+ <span>
36
+ {run.runId ? <>Run {run.runId} (<Link href={proceduresHref(demo)}>Procedures</Link>)</> : run.triggerStatus === "started" ? "Run record not linked yet" : "No run record"}
37
+ {run.runRecord === "missing" ? " (the run record was not found)" : ""}
38
+ {run.runRecord === "unavailable" ? " (the run record could not be read)" : ""}
39
+ </span>
40
+ <span>
41
+ {run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)} data-automation-story-link>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
42
+ </span>
43
+ <span>Cost: unknown ({run.costNote})</span>
44
+ </li>
45
+ );
46
+ }
47
+
48
+ function Detail({ record, demo }: { record: AutomationRecord; demo: boolean }) {
49
+ const notice = noticeText(record.notice);
50
+ return (
51
+ <article className="news-desk-doctrine-card" data-automation-detail={record.id}>
52
+ <div className="news-desk-doctrine-card__header"><div><h3>{record.title}</h3><span>{record.description ?? record.id}</span></div></div>
53
+ <dl className="news-desk-monitoring__usage">
54
+ <div><dt>Trigger</dt><dd>{triggerText(record.trigger)}{record.supported ? "" : ` (${record.supportNote ?? "not yet supported"})`}</dd></div>
55
+ <div><dt>Procedure</dt><dd>{record.procedureKey}</dd></div>
56
+ <div><dt>Input</dt><dd>{Object.keys(record.inputTemplate).length ? Object.entries(record.inputTemplate).map(([name, template]) => `${name} = ${template}`).join("; ") : "none"}</dd></div>
57
+ <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>
59
+ <div><dt>Router</dt><dd data-automation-link={record.link?.state ?? "unchecked"}>{linkText(record.link)}</dd></div>
60
+ </dl>
61
+ {notice ? <p className="news-desk-delegation-note" data-automation-notice>{notice}</p> : null}
62
+ <h4>Recent runs</h4>
63
+ {record.runs.length ? (
64
+ <ul className="news-desk-monitoring__list" data-automation-runs>
65
+ {record.runs.map((run) => <RunLine key={run.runId ?? `${run.sourceId}-${run.startedAt}`} run={run} demo={demo} />)}
66
+ </ul>
67
+ ) : <p data-automation-no-runs>No runs yet.</p>}
68
+ {record.skippedAlreadyHadReference > 0 ? (
69
+ <p className="news-desk-delegation-note" data-automation-skipped>
70
+ {record.skippedAlreadyHadReference} {record.skippedAlreadyHadReference === 1 ? "item was" : "items were"} not started because the Reference already existed.
71
+ </p>
72
+ ) : null}
73
+ </article>
74
+ );
75
+ }
76
+
77
+ function ListItem({ record, selected, onSelect }: { record: AutomationRecord; selected: boolean; onSelect: (id: string) => void }) {
78
+ const last = record.lastRun;
79
+ return (
80
+ <li data-automation={record.id}>
81
+ <div>
82
+ <strong>{record.title}</strong>
83
+ <span>{triggerText(record.trigger)}{record.supported ? "" : ` (${record.supportNote ?? "not yet supported"})`}</span>
84
+ </div>
85
+ <span>Procedure: {record.procedureKey}</span>
86
+ <span>{enabledText(record)} ({ENABLED_NOTE})</span>
87
+ <span data-automation-last-run>
88
+ Last run: <span aria-hidden="true">{statusMark(last?.status)} </span>{statusText(last?.status)}{last ? `, ${formatUtc(last.startedAt)}` : ""}
89
+ </span>
90
+ <button type="button" aria-pressed={selected} data-automation-select={record.id} onClick={() => onSelect(record.id)}>
91
+ {selected ? "Showing runs" : "Show runs"}
92
+ </button>
93
+ </li>
94
+ );
95
+ }
96
+
97
+ /** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
98
+ export function AutomationView({
99
+ result,
100
+ selectedId,
101
+ onSelect,
102
+ demo = false,
103
+ }: {
104
+ result: AutomationResult | null;
105
+ selectedId: string | null;
106
+ onSelect: (id: string) => void;
107
+ demo?: boolean;
108
+ }) {
109
+ let body: React.ReactNode;
110
+ if (result === null) {
111
+ body = <Message kind="loading" title="Loading automations" />;
112
+ } else if ("error" in result) {
113
+ body = result.error === "forbidden"
114
+ ? <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>
115
+ : <Message kind="error" title="Automations could not be read">The request was not understood. Reload the page.</Message>;
116
+ } else if (result.state === "unavailable") {
117
+ body = <Message kind="unavailable" title="Automations could not be read right now">Nothing is affected. Try again in a minute.</Message>;
118
+ } else if (result.state === "empty") {
119
+ 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>;
120
+ } else {
121
+ const selected = result.automations.find((record) => record.id === selectedId) ?? (result.automations.length === 1 ? result.automations[0] : null);
122
+ body = (
123
+ <>
124
+ <article className="news-desk-doctrine-card" data-automation-section="list">
125
+ <div className="news-desk-doctrine-card__header"><div><h3>Automations</h3><span>What starts a procedure by itself</span></div></div>
126
+ <ul className="news-desk-monitoring__list">
127
+ {result.automations.map((record) => <ListItem key={record.id} record={record} selected={selected?.id === record.id} onSelect={onSelect} />)}
128
+ </ul>
129
+ {result.monitoring === "not_configured" ? <p className="news-desk-delegation-note" data-automation-notice>{noticeText("monitoring_not_configured")}</p> : null}
130
+ </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>}
132
+ </>
133
+ );
134
+ }
135
+ return (
136
+ <div data-news-desk-admin-panel="automations">
137
+ <article className="news-desk-doctrine-card">
138
+ <div className="news-desk-doctrine-card__header">
139
+ <div>
140
+ <h3>Automation</h3>
141
+ <span>Procedures that start on their own. Read only here; an operator changes them with the command line.</span>
142
+ </div>
143
+ </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}
145
+ </article>
146
+ {body}
147
+ </div>
148
+ );
149
+ }
@@ -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";
@@ -4315,6 +4316,15 @@ function AdministrationDeskView({
4315
4316
  <strong>Procedures</strong>
4316
4317
  <span>Procedure registry and versioned Tactus code</span>
4317
4318
  </Link>
4319
+ <Link
4320
+ href={administrationPanelHref("automations", isDemo)}
4321
+ data-news-desk-admin-nav="automations"
4322
+ data-active={administrationPanel === "automations" || undefined}
4323
+ onClick={() => onPanelChange("automations")}
4324
+ >
4325
+ <strong>Automation</strong>
4326
+ <span>Procedures that start on their own</span>
4327
+ </Link>
4318
4328
  <Link
4319
4329
  href={administrationPanelHref("delegations", isDemo)}
4320
4330
  data-news-desk-admin-nav="delegations"
@@ -4381,6 +4391,7 @@ function AdministrationDeskView({
4381
4391
  {administrationPanel === "newsletter" ? <NewsletterPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4382
4392
  {administrationPanel === "moderation" ? <ModerationPanel /> : null}
4383
4393
  {administrationPanel === "editorial-checks" ? <EditorialChecksAdminPanel /> : null}
4394
+ {administrationPanel === "automations" ? <AutomationPanel isDemo={isDemo} /> : null}
4384
4395
  {administrationPanel === "monitoring" ? <MonitoringPanel isAdmin={canManageUsers} isDemo={isDemo} /> : null}
4385
4396
  {administrationPanel === "delegations" ? <AdministrationDelegationsPanel disabled={disabled} /> : null}
4386
4397
  {administrationPanel === "users" ? (
@@ -12855,7 +12866,7 @@ function readerPluginEnabled(): boolean {
12855
12866
  }
12856
12867
 
12857
12868
  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;
12869
+ if (value === "policies" || value === "sections" || value === "procedures" || value === "automations" || value === "delegations" || value === "editorial-checks" || value === "monitoring") return value;
12859
12870
  if (value === "signups" && signupPluginEnabled()) return value;
12860
12871
  if (value === "newsletter" && newsletterPluginEnabled(demo)) return value;
12861
12872
  if (value === "moderation" && readerPluginEnabled()) return value;
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { AutomationResult } 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
+ }
@@ -0,0 +1,65 @@
1
+ import type { AutomationOverview } from "./automation-overview";
2
+
3
+ const COST_NOTE = "not recorded: the procedure runner does not record token use or cost on a ProcedureRun yet";
4
+
5
+ /** A realistic overview for tests and the `?demo=1` newsroom: one live monitor automation and one stored schedule. */
6
+ export const FIXTURE_AUTOMATION_OVERVIEW: AutomationOverview = {
7
+ state: "ready",
8
+ asOf: "2026-10-23T12:00:00Z",
9
+ monitoring: "ready",
10
+ automations: [
11
+ {
12
+ id: "example.ttab.ruling-analysis",
13
+ title: "Analyze new TTAB rulings",
14
+ description: "Drafts a story for each new Board decision.",
15
+ enabled: true,
16
+ enabledNote: "display only until the router honours it",
17
+ trigger: { kind: "monitor", config: { monitorId: "ttab", publicationId: "example" } },
18
+ supported: true,
19
+ supportNote: null,
20
+ procedureKey: "example.ttab.ruling-analysis",
21
+ inputTemplate: { proceeding: "{sourceIdPrefix}", opinion_url: "{documentUrl}" },
22
+ limits: { timeoutSeconds: 840, maxConcurrent: 1 },
23
+ limitsNote: "recorded only: the runner does not enforce these yet",
24
+ createdAt: "2026-10-23T10:00:00Z",
25
+ updatedAt: "2026-10-23T10:00:00Z",
26
+ link: { state: "linked", text: "The router's trigger matches this record.", routerMode: "live", routerEnabled: true },
27
+ notice: null,
28
+ skippedAlreadyHadReference: 2,
29
+ lastRun: null,
30
+ 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 },
40
+ ],
41
+ },
42
+ {
43
+ id: "example.weekly-digest",
44
+ title: "Weekly digest",
45
+ description: null,
46
+ enabled: false,
47
+ enabledNote: "display only until the router honours it",
48
+ trigger: { kind: "schedule", config: { cron: "0 9 * * 1" } },
49
+ supported: false,
50
+ supportNote: "not yet supported",
51
+ procedureKey: "example.weekly-digest",
52
+ inputTemplate: {},
53
+ limits: { timeoutSeconds: 840, maxConcurrent: 1 },
54
+ limitsNote: "recorded only: the runner does not enforce these yet",
55
+ createdAt: "2026-10-23T10:05:00Z",
56
+ updatedAt: "2026-10-23T10:05:00Z",
57
+ link: { state: "not_applicable", text: "not yet supported" },
58
+ notice: "not yet supported",
59
+ skippedAlreadyHadReference: 0,
60
+ lastRun: null,
61
+ runs: [],
62
+ },
63
+ ],
64
+ };
65
+ FIXTURE_AUTOMATION_OVERVIEW.automations[0].lastRun = FIXTURE_AUTOMATION_OVERVIEW.automations[0].runs[2];
@@ -0,0 +1,138 @@
1
+ /** Shapes and wording for the newsroom Automation page. src/papyrus_content/automations.py produces them. */
2
+ import { formatUtc } from "./monitoring-overview";
3
+ import { getNewsroomNavHref } from "./newsroom-nav";
4
+
5
+ export { formatUtc };
6
+
7
+ export type AutomationRun = {
8
+ sourceId: string | null;
9
+ referenceId: string | null;
10
+ triggerStatus: string | null;
11
+ runId: string | null;
12
+ status: string | null;
13
+ startedAt: string | null;
14
+ finishedAt: string | null;
15
+ durationSeconds: number | null;
16
+ error: string | null;
17
+ runRecord: "found" | "missing" | "unavailable" | "none";
18
+ storyId: string | null;
19
+ storyVersionId: string | null;
20
+ storyTitle: string | null;
21
+ costUsd: number | null;
22
+ costNote: string;
23
+ };
24
+
25
+ export type AutomationLink = {
26
+ state: "linked" | "input_differs" | "trigger_not_found" | "publication_not_in_router" | "router_not_found" | "not_applicable";
27
+ text: string;
28
+ routerMode?: string | null;
29
+ routerEnabled?: boolean;
30
+ };
31
+
32
+ export type AutomationNotice = "monitoring_not_configured" | "monitoring_unavailable" | "other_publication" | "publication_not_routed" | "not yet supported";
33
+
34
+ export type AutomationRecord = {
35
+ id: string;
36
+ title: string;
37
+ description: string | null;
38
+ enabled: boolean;
39
+ enabledNote: string;
40
+ trigger: { kind: "monitor" | "schedule" | "event" | "manual"; config: Record<string, unknown> };
41
+ supported: boolean;
42
+ supportNote: string | null;
43
+ procedureKey: string;
44
+ inputTemplate: Record<string, string>;
45
+ limits: { timeoutSeconds?: number; maxConcurrent?: number; budgetUsdPerRun?: number };
46
+ limitsNote: string;
47
+ createdAt: string | null;
48
+ updatedAt: string | null;
49
+ link: AutomationLink | null;
50
+ runs: AutomationRun[];
51
+ lastRun: AutomationRun | null;
52
+ skippedAlreadyHadReference: number;
53
+ notice: AutomationNotice | null;
54
+ };
55
+
56
+ export type AutomationOverview = {
57
+ state: "ready" | "empty";
58
+ asOf: string;
59
+ monitoring: "ready" | "not_configured";
60
+ automations: AutomationRecord[];
61
+ };
62
+
63
+ export type AutomationResult = AutomationOverview | { state: "unavailable" } | { error: "forbidden" | "bad_request" };
64
+
65
+ const KIND_WORDS: Record<string, string> = { monitor: "Monitor", schedule: "Schedule", event: "Event", manual: "Manual" };
66
+
67
+ export function triggerText(trigger: AutomationRecord["trigger"]): string {
68
+ const kind = KIND_WORDS[trigger.kind] ?? trigger.kind;
69
+ if (trigger.kind === "monitor") {
70
+ return `${kind} ${String(trigger.config.monitorId ?? "?")}, delivering to ${String(trigger.config.publicationId ?? "?")}`;
71
+ }
72
+ return kind;
73
+ }
74
+
75
+ export const ENABLED_NOTE = "display only until the router honours it";
76
+
77
+ export function enabledText(record: Pick<AutomationRecord, "enabled">): string {
78
+ return record.enabled ? "Enabled" : "Disabled";
79
+ }
80
+
81
+ const STATUS_WORDS: Record<string, string> = {
82
+ completed: "Completed",
83
+ failed: "Failed",
84
+ running: "Running",
85
+ starting: "Starting",
86
+ started: "Started, no result recorded yet",
87
+ startFailed: "Could not start",
88
+ notRunnable: "Not runnable",
89
+ notTriggered: "Not started",
90
+ };
91
+ const STATUS_MARKS: Record<string, string> = { completed: "✓", failed: "✕", startFailed: "✕", notRunnable: "✕", started: "…", starting: "…", running: "…" };
92
+
93
+ export function statusText(status: string | null | undefined): string {
94
+ return status ? (STATUS_WORDS[status] ?? status) : "No runs";
95
+ }
96
+
97
+ export function statusMark(status: string | null | undefined): string {
98
+ return status ? (STATUS_MARKS[status] ?? "") : "";
99
+ }
100
+
101
+ export function durationText(seconds: number | null | undefined): string {
102
+ if (seconds === null || seconds === undefined) return "duration not recorded";
103
+ if (seconds < 90) return `${Math.round(seconds)} s`;
104
+ return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
105
+ }
106
+
107
+ const LINK_WORDS: Record<AutomationLink["state"], string> = {
108
+ linked: "Linked: the router's trigger matches this record.",
109
+ input_differs: "Differs: the router's input template is not the same as this record's.",
110
+ trigger_not_found: "Not linked: the router has no trigger for this procedure.",
111
+ publication_not_in_router: "Not linked: the router does not deliver to this publication.",
112
+ router_not_found: "Not linked: the monitor has no router configuration.",
113
+ not_applicable: "Not yet supported",
114
+ };
115
+
116
+ export function linkText(link: AutomationLink | null): string {
117
+ return link ? LINK_WORDS[link.state] ?? link.text : "Not checked";
118
+ }
119
+
120
+ const NOTICE_WORDS: Record<string, string> = {
121
+ monitoring_not_configured: "Monitoring is not connected to this site's account, so run history cannot be shown.",
122
+ monitoring_unavailable: "Run history could not be read right now. The record is shown without it.",
123
+ other_publication: "This automation belongs to another publication, so its runs are not shown here.",
124
+ publication_not_routed: "This publication is not one of the router's outputs, so there are no runs to show.",
125
+ "not yet supported": "Only monitor automations run today. This one is stored but nothing starts it.",
126
+ };
127
+
128
+ export function noticeText(notice: AutomationNotice | null): string | null {
129
+ return notice ? (NOTICE_WORDS[notice] ?? notice) : null;
130
+ }
131
+
132
+ export function storyHref(storyId: string, demo?: boolean): string {
133
+ return getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(storyId)}`, demo);
134
+ }
135
+
136
+ export function proceduresHref(demo?: boolean): string {
137
+ return getNewsroomNavHref("/newsroom/administration/procedures", demo);
138
+ }
package/package.json CHANGED
@@ -75,6 +75,8 @@
75
75
  "./components/archive-grid": "./components/archive-grid.tsx",
76
76
  "./components/archive-shell": "./components/archive-shell.tsx",
77
77
  "./components/article-video": "./components/article-video.tsx",
78
+ "./components/automation-panel": "./components/automation-panel.tsx",
79
+ "./components/automation-view": "./components/automation-view.tsx",
78
80
  "./components/blog-page-background": "./components/blog-page-background.tsx",
79
81
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
80
82
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
@@ -176,6 +178,9 @@
176
178
  "./lib/article-flow": "./lib/article-flow.ts",
177
179
  "./lib/article-frame-solver": "./lib/article-frame-solver.ts",
178
180
  "./lib/articles": "./lib/articles.ts",
181
+ "./lib/automation-client": "./lib/automation-client.ts",
182
+ "./lib/automation-fixture": "./lib/automation-fixture.ts",
183
+ "./lib/automation-overview": "./lib/automation-overview.ts",
179
184
  "./lib/blog-feature-solver": "./lib/blog-feature-solver.ts",
180
185
  "./lib/blog-rhythm": "./lib/blog-rhythm.ts",
181
186
  "./lib/brand-fonts-watch": "./lib/brand-fonts-watch.ts",
@@ -434,7 +439,7 @@
434
439
  "optional": true
435
440
  }
436
441
  },
437
- "version": "5.31.0",
442
+ "version": "5.32.0",
438
443
  "dependencies": {
439
444
  "@aws-amplify/adapter-nextjs": "^1.7.3",
440
445
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.31.0",
2
+ "version": "5.32.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",