@anthusai/papyrus 5.30.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.
Files changed (31) hide show
  1. package/amplify/data/resource.js +19 -0
  2. package/amplify/data/resource.ts +20 -0
  3. package/amplify/data/schema.js +21 -0
  4. package/amplify/data/schema.ts +22 -0
  5. package/amplify/functions/automation-view/handler.py +11 -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 +16 -0
  9. package/amplify/functions/automation-view/schema.ts +18 -0
  10. package/amplify/functions/automation-view/wire.js +32 -0
  11. package/amplify/functions/automation-view/wire.ts +46 -0
  12. package/amplify/plugins/reader-steering/config.js +8 -1
  13. package/amplify/plugins/reader-steering/config.ts +13 -1
  14. package/amplify/plugins/reader-steering/function/handler.py +209 -18
  15. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  16. package/amplify/plugins/reader-steering/papyrus.plugin.js +10 -2
  17. package/amplify/plugins/reader-steering/papyrus.plugin.ts +10 -2
  18. package/amplify/plugins/reader-steering/schema.js +65 -1
  19. package/amplify/plugins/reader-steering/schema.ts +65 -1
  20. package/amplify/site-backend.js +4 -0
  21. package/amplify/site-backend.ts +4 -0
  22. package/app/api/reader/flag/route.ts +9 -0
  23. package/components/automation-panel.tsx +39 -0
  24. package/components/automation-view.tsx +149 -0
  25. package/components/topic-steering-workspace.tsx +13 -2
  26. package/lib/automation-client.ts +29 -0
  27. package/lib/automation-fixture.ts +65 -0
  28. package/lib/automation-overview.ts +138 -0
  29. package/lib/reader-api.ts +1 -1
  30. package/package.json +7 -1
  31. package/routes.manifest.json +12 -1
@@ -14,8 +14,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
14
14
  export const readerSteeringManifest = {
15
15
  name: "reader-steering",
16
16
  contract: 1,
17
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
18
- mutations: ["moderateReader"],
17
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
+ mutations: ["moderateReader", "fileProposal", "transitionProposal"],
19
19
  guest: { mutations: [] },
20
20
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
21
21
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -26,6 +26,7 @@ export function readerActionsSettings(config, allowInterimSubjects) {
26
26
  dimensions: config.dimensions,
27
27
  limits: config.limits,
28
28
  submissions: config.submissions,
29
+ subjects: config.subjects,
29
30
  comments: config.comments,
30
31
  moderation: config.moderation,
31
32
  reservedHandles: config.reservedHandles,
@@ -38,6 +39,7 @@ export function readerSnapshotSettings(config) {
38
39
  return {
39
40
  dimensions: config.dimensions,
40
41
  submissions: config.submissions,
42
+ subjects: config.subjects,
41
43
  karma: config.karma,
42
44
  ranking: config.ranking,
43
45
  limits: { newAccountHours: config.limits.newAccountHours },
@@ -100,6 +102,7 @@ export const readerSteeringPlugin = {
100
102
  publicSettings: (config) => ({
101
103
  contract: readerSteeringManifest.contract,
102
104
  dimensions: config.dimensions,
105
+ subjects: { types: config.subjects.types },
103
106
  moderation: { mode: config.moderation.mode },
104
107
  }),
105
108
  wire(backend, config, context) {
@@ -110,10 +113,12 @@ export const readerSteeringPlugin = {
110
113
  ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
111
114
  ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
112
115
  ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
116
+ ["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
113
117
  ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
114
118
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
115
119
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
116
120
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
121
+ ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
117
122
  ]) {
118
123
  tables[model].grantReadWriteData(lambda);
119
124
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -125,10 +130,13 @@ export const readerSteeringPlugin = {
125
130
  tables.PublishedItem.grantReadData(lambda);
126
131
  lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
127
132
  tables.ReaderVote.grantReadData(contentActions);
133
+ tables.ReaderFlag.grantReadData(contentActions);
128
134
  tables.ReaderProfile.grantReadWriteData(contentActions);
129
135
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
130
136
  tables.ReaderSubmission.grantReadWriteData(contentActions);
131
137
  tables.ReaderComment.grantReadWriteData(contentActions);
138
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review.
139
+ tables.Proposal.grantReadWriteData(contentActions);
132
140
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
133
141
  if (config.reservedConcurrency !== undefined) {
134
142
  lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
@@ -21,8 +21,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
21
21
  export const readerSteeringManifest: PluginManifest = {
22
22
  name: "reader-steering",
23
23
  contract: 1,
24
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
25
- mutations: ["moderateReader"],
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
+ mutations: ["moderateReader", "fileProposal", "transitionProposal"],
26
26
  guest: { mutations: [] },
27
27
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
28
28
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -34,6 +34,7 @@ export function readerActionsSettings(config: ReaderSteeringConfig, allowInterim
34
34
  dimensions: config.dimensions,
35
35
  limits: config.limits,
36
36
  submissions: config.submissions,
37
+ subjects: config.subjects,
37
38
  comments: config.comments,
38
39
  moderation: config.moderation,
39
40
  reservedHandles: config.reservedHandles,
@@ -47,6 +48,7 @@ export function readerSnapshotSettings(config: ReaderSteeringConfig) {
47
48
  return {
48
49
  dimensions: config.dimensions,
49
50
  submissions: config.submissions,
51
+ subjects: config.subjects,
50
52
  karma: config.karma,
51
53
  ranking: config.ranking,
52
54
  limits: { newAccountHours: config.limits.newAccountHours },
@@ -118,6 +120,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
118
120
  publicSettings: (config) => ({
119
121
  contract: readerSteeringManifest.contract,
120
122
  dimensions: config.dimensions,
123
+ subjects: { types: config.subjects.types },
121
124
  moderation: { mode: config.moderation.mode },
122
125
  }),
123
126
  wire(backend, config, context: PluginWiringContext) {
@@ -129,10 +132,12 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
129
132
  ["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
130
133
  ["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
131
134
  ["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
135
+ ["PAPYRUS_READER_FLAG_TABLE", "ReaderFlag"],
132
136
  ["PAPYRUS_READER_SUBMISSION_TABLE", "ReaderSubmission"],
133
137
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
134
138
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
135
139
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
140
+ ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
136
141
  ] as const) {
137
142
  tables[model].grantReadWriteData(lambda);
138
143
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -144,10 +149,13 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
144
149
  tables.PublishedItem.grantReadData(lambda);
145
150
  lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
146
151
  tables.ReaderVote.grantReadData(contentActions);
152
+ tables.ReaderFlag.grantReadData(contentActions);
147
153
  tables.ReaderProfile.grantReadWriteData(contentActions);
148
154
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
149
155
  tables.ReaderSubmission.grantReadWriteData(contentActions);
150
156
  tables.ReaderComment.grantReadWriteData(contentActions);
157
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review.
158
+ tables.Proposal.grantReadWriteData(contentActions);
151
159
 
152
160
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
153
161
  if (config.reservedConcurrency !== undefined) {
@@ -3,7 +3,7 @@ import { readerActions } from "./function/resource.js";
3
3
  /**
4
4
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
5
5
  * tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
6
- * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
6
+ * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote (`subjectKey#readerId#kind` for a flag; the key is the bare id for items and submissions and `type:id` for the rest) and
7
7
  * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
8
8
  */
9
9
  const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
@@ -32,6 +32,7 @@ export function readerSteeringSchemaDefinitions() {
32
32
  .model({
33
33
  id: a.id().required(),
34
34
  subjectKey: a.string().required(),
35
+ subjectType: a.string(),
35
36
  readerId: a.string().required(),
36
37
  dimension: a.string().required(),
37
38
  value: a.integer().required(),
@@ -39,6 +40,21 @@ export function readerSteeringSchemaDefinitions() {
39
40
  })
40
41
  .disableOperations(["subscriptions"])
41
42
  .authorization(staffOnly),
43
+ ReaderFlag: a
44
+ .model({
45
+ id: a.id().required(),
46
+ subjectKey: a.string().required(),
47
+ subjectType: a.string().required(),
48
+ readerId: a.string().required(),
49
+ kind: a.string().required(),
50
+ reason: a.string().required(),
51
+ targetKey: a.string(),
52
+ suggestedName: a.string(),
53
+ voterRole: a.string().required(),
54
+ createdAt: a.datetime().required(),
55
+ })
56
+ .disableOperations(["subscriptions"])
57
+ .authorization(staffOnly),
42
58
  ReaderComment: a
43
59
  .model({
44
60
  id: a.id().required(),
@@ -118,6 +134,54 @@ export function readerSteeringSchemaDefinitions() {
118
134
  .disableOperations(["subscriptions"])
119
135
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
120
136
  .authorization(staffOnly),
137
+ Proposal: a
138
+ .model({
139
+ id: a.id().required(),
140
+ kind: a.string().required(),
141
+ title: a.string().required(),
142
+ summary: a.string(),
143
+ body: a.string().required(),
144
+ targets: a.json().required(),
145
+ evidence: a.json(),
146
+ proposer: a.string().required(),
147
+ state: a.string().required(),
148
+ history: a.json().required(),
149
+ communityResult: a.json(),
150
+ createdAt: a.datetime().required(),
151
+ })
152
+ .disableOperations(["subscriptions"])
153
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
154
+ .authorization(staffOnly),
155
+ ProposalReceipt: a.customType({
156
+ status: a.string().required(),
157
+ id: a.string(),
158
+ error: a.string(),
159
+ message: a.string(),
160
+ }),
161
+ fileProposal: a
162
+ .mutation()
163
+ .arguments({
164
+ kind: a.string().required(),
165
+ title: a.string().required(),
166
+ body: a.string().required(),
167
+ summary: a.string(),
168
+ targets: a.json(),
169
+ evidence: a.json(),
170
+ })
171
+ .returns(a.ref("ProposalReceipt"))
172
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
173
+ .handler(a.handler.function(readerActions)),
174
+ transitionProposal: a
175
+ .mutation()
176
+ .arguments({
177
+ proposalId: a.string().required(),
178
+ state: a.string().required(),
179
+ reason: a.string(),
180
+ result: a.string(),
181
+ })
182
+ .returns(a.ref("ProposalReceipt"))
183
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
184
+ .handler(a.handler.function(readerActions)),
121
185
  ModerationReceipt: a.customType({
122
186
  status: a.string().required(),
123
187
  id: a.string(),
@@ -4,7 +4,7 @@ import { readerActions } from "./function/resource";
4
4
  /**
5
5
  * Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
6
6
  * tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
7
- * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote and
7
+ * the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote (`subjectKey#readerId#kind` for a flag; the key is the bare id for items and submissions and `type:id` for the rest) and
8
8
  * `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
9
9
  */
10
10
  const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
@@ -34,6 +34,7 @@ export function readerSteeringSchemaDefinitions() {
34
34
  .model({
35
35
  id: a.id().required(),
36
36
  subjectKey: a.string().required(),
37
+ subjectType: a.string(),
37
38
  readerId: a.string().required(),
38
39
  dimension: a.string().required(),
39
40
  value: a.integer().required(),
@@ -41,6 +42,21 @@ export function readerSteeringSchemaDefinitions() {
41
42
  })
42
43
  .disableOperations(["subscriptions"])
43
44
  .authorization(staffOnly),
45
+ ReaderFlag: a
46
+ .model({
47
+ id: a.id().required(),
48
+ subjectKey: a.string().required(),
49
+ subjectType: a.string().required(),
50
+ readerId: a.string().required(),
51
+ kind: a.string().required(),
52
+ reason: a.string().required(),
53
+ targetKey: a.string(),
54
+ suggestedName: a.string(),
55
+ voterRole: a.string().required(),
56
+ createdAt: a.datetime().required(),
57
+ })
58
+ .disableOperations(["subscriptions"])
59
+ .authorization(staffOnly),
44
60
  ReaderComment: a
45
61
  .model({
46
62
  id: a.id().required(),
@@ -120,6 +136,54 @@ export function readerSteeringSchemaDefinitions() {
120
136
  .disableOperations(["subscriptions"])
121
137
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
122
138
  .authorization(staffOnly),
139
+ Proposal: a
140
+ .model({
141
+ id: a.id().required(),
142
+ kind: a.string().required(),
143
+ title: a.string().required(),
144
+ summary: a.string(),
145
+ body: a.string().required(),
146
+ targets: a.json().required(),
147
+ evidence: a.json(),
148
+ proposer: a.string().required(),
149
+ state: a.string().required(),
150
+ history: a.json().required(),
151
+ communityResult: a.json(),
152
+ createdAt: a.datetime().required(),
153
+ })
154
+ .disableOperations(["subscriptions"])
155
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
156
+ .authorization(staffOnly),
157
+ ProposalReceipt: a.customType({
158
+ status: a.string().required(),
159
+ id: a.string(),
160
+ error: a.string(),
161
+ message: a.string(),
162
+ }),
163
+ fileProposal: a
164
+ .mutation()
165
+ .arguments({
166
+ kind: a.string().required(),
167
+ title: a.string().required(),
168
+ body: a.string().required(),
169
+ summary: a.string(),
170
+ targets: a.json(),
171
+ evidence: a.json(),
172
+ })
173
+ .returns(a.ref("ProposalReceipt"))
174
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
175
+ .handler(a.handler.function(readerActions)),
176
+ transitionProposal: a
177
+ .mutation()
178
+ .arguments({
179
+ proposalId: a.string().required(),
180
+ state: a.string().required(),
181
+ reason: a.string(),
182
+ result: a.string(),
183
+ })
184
+ .returns(a.ref("ProposalReceipt"))
185
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
186
+ .handler(a.handler.function(readerActions)),
123
187
  ModerationReceipt: a.customType({
124
188
  status: a.string().required(),
125
189
  id: a.string(),
@@ -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,9 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { handleReaderRequest } from "../../../../lib/reader-api";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const runtime = "nodejs";
6
+
7
+ export function POST(request: NextRequest) {
8
+ return handleReaderRequest(request, "flag");
9
+ }
@@ -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
+ }