@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.
- package/amplify/data/resource.js +19 -0
- package/amplify/data/resource.ts +20 -0
- package/amplify/data/schema.js +21 -0
- package/amplify/data/schema.ts +22 -0
- package/amplify/functions/automation-view/handler.py +11 -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 +16 -0
- package/amplify/functions/automation-view/schema.ts +18 -0
- package/amplify/functions/automation-view/wire.js +32 -0
- package/amplify/functions/automation-view/wire.ts +46 -0
- package/amplify/plugins/reader-steering/config.js +8 -1
- package/amplify/plugins/reader-steering/config.ts +13 -1
- package/amplify/plugins/reader-steering/function/handler.py +209 -18
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +10 -2
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +10 -2
- package/amplify/plugins/reader-steering/schema.js +65 -1
- package/amplify/plugins/reader-steering/schema.ts +65 -1
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/components/automation-panel.tsx +39 -0
- package/components/automation-view.tsx +149 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/lib/automation-client.ts +29 -0
- package/lib/automation-fixture.ts +65 -0
- package/lib/automation-overview.ts +138 -0
- package/lib/reader-api.ts +1 -1
- package/package.json +7 -1
- 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(),
|
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,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)} · {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
|
+
}
|