@anthusai/papyrus 2.0.0-next.74 → 2.0.0-next.76
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 +50 -0
- package/amplify/data/resource.ts +53 -0
- package/amplify/data/schema.js +47 -0
- package/amplify/data/schema.ts +50 -0
- package/amplify/functions/automation-view/handler.py +9 -3
- package/amplify/functions/automation-view/schema.js +11 -2
- package/amplify/functions/automation-view/schema.ts +11 -2
- package/amplify/functions/automation-view/wire.js +28 -4
- package/amplify/functions/automation-view/wire.ts +34 -4
- package/amplify/plugins/reader-steering/config.js +3 -1
- package/amplify/plugins/reader-steering/config.ts +11 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +2 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +2 -0
- package/components/automation-panel.tsx +12 -2
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +50 -7
- package/components/newsroom-ops-overview.tsx +18 -0
- package/components/notification-center.tsx +262 -0
- package/components/topic-steering-workspace.tsx +4 -1
- package/lib/automation-client.ts +26 -1
- package/lib/automation-fixture.ts +46 -18
- package/lib/automation-overview.ts +96 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/package.json +6 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -1152,6 +1152,8 @@ const schema = a.schema({
|
|
|
1152
1152
|
error: a.json(),
|
|
1153
1153
|
attempt: a.integer(),
|
|
1154
1154
|
newsroomFeedKey: a.string(),
|
|
1155
|
+
usage: a.json(),
|
|
1156
|
+
costUsd: a.float(),
|
|
1155
1157
|
})
|
|
1156
1158
|
.secondaryIndexes((index) => [
|
|
1157
1159
|
index("procedureId").sortKeys(["requestedAt"]).queryField("listProcedureRunsByProcedureAndRequestedAt"),
|
|
@@ -1182,6 +1184,54 @@ const schema = a.schema({
|
|
|
1182
1184
|
allow.group("editor").to(["read", "create", "update"]),
|
|
1183
1185
|
allow.group(adminGroup).to(authoringOperations),
|
|
1184
1186
|
]),
|
|
1187
|
+
/** A human review point raised by a step on any item (PPY-ab6a50). Review is not a story state. */
|
|
1188
|
+
ReviewRequest: a
|
|
1189
|
+
.model({
|
|
1190
|
+
id: a.id().required(),
|
|
1191
|
+
subject: a.json().required(),
|
|
1192
|
+
mode: a.string().required(),
|
|
1193
|
+
reason: a.string().required(),
|
|
1194
|
+
ask: a.string(),
|
|
1195
|
+
raisedBy: a.string().required(),
|
|
1196
|
+
raisedAt: a.datetime().required(),
|
|
1197
|
+
deadline: a.datetime(),
|
|
1198
|
+
state: a.string().required(),
|
|
1199
|
+
answer: a.json(),
|
|
1200
|
+
links: a.json(),
|
|
1201
|
+
history: a.json(),
|
|
1202
|
+
appliedIn: a.json(),
|
|
1203
|
+
updatedAt: a.datetime().required(),
|
|
1204
|
+
})
|
|
1205
|
+
.authorization((allow) => [
|
|
1206
|
+
allow.group("editor").to(["read", "create", "update"]),
|
|
1207
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1208
|
+
]),
|
|
1209
|
+
/** One user's read mark on one review request; id is "<userKey>#<requestId>". */
|
|
1210
|
+
ReviewReadMark: a
|
|
1211
|
+
.model({
|
|
1212
|
+
id: a.id().required(),
|
|
1213
|
+
requestId: a.string().required(),
|
|
1214
|
+
userKey: a.string().required(),
|
|
1215
|
+
readAt: a.datetime().required(),
|
|
1216
|
+
})
|
|
1217
|
+
.authorization((allow) => [
|
|
1218
|
+
allow.groups(["editor", adminGroup]).to(["read", "create", "update"]),
|
|
1219
|
+
]),
|
|
1220
|
+
/** Who is told about review requests: scope default, mode or automation. Changed by data, not code. */
|
|
1221
|
+
ReviewRecipientRule: a
|
|
1222
|
+
.model({
|
|
1223
|
+
id: a.id().required(),
|
|
1224
|
+
scope: a.string().required(),
|
|
1225
|
+
key: a.string(),
|
|
1226
|
+
roles: a.string().array(),
|
|
1227
|
+
emails: a.string().array(),
|
|
1228
|
+
updatedBy: a.string(),
|
|
1229
|
+
updatedAt: a.datetime().required(),
|
|
1230
|
+
})
|
|
1231
|
+
.authorization((allow) => [
|
|
1232
|
+
allow.group("editor").to(["read"]),
|
|
1233
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1234
|
+
]),
|
|
1185
1235
|
NewsroomSection: a
|
|
1186
1236
|
.model({
|
|
1187
1237
|
id: a.id().required(),
|
package/amplify/data/resource.ts
CHANGED
|
@@ -1247,6 +1247,8 @@ const schema = a.schema({
|
|
|
1247
1247
|
error: a.json(),
|
|
1248
1248
|
attempt: a.integer(),
|
|
1249
1249
|
newsroomFeedKey: a.string(),
|
|
1250
|
+
usage: a.json(),
|
|
1251
|
+
costUsd: a.float(),
|
|
1250
1252
|
})
|
|
1251
1253
|
.secondaryIndexes((index) => [
|
|
1252
1254
|
index("procedureId").sortKeys(["requestedAt"]).queryField("listProcedureRunsByProcedureAndRequestedAt"),
|
|
@@ -1279,6 +1281,57 @@ const schema = a.schema({
|
|
|
1279
1281
|
allow.group(adminGroup).to(authoringOperations),
|
|
1280
1282
|
]),
|
|
1281
1283
|
|
|
1284
|
+
/** A human review point raised by a step on any item (PPY-ab6a50). Review is not a story state. */
|
|
1285
|
+
ReviewRequest: a
|
|
1286
|
+
.model({
|
|
1287
|
+
id: a.id().required(),
|
|
1288
|
+
subject: a.json().required(),
|
|
1289
|
+
mode: a.string().required(),
|
|
1290
|
+
reason: a.string().required(),
|
|
1291
|
+
ask: a.string(),
|
|
1292
|
+
raisedBy: a.string().required(),
|
|
1293
|
+
raisedAt: a.datetime().required(),
|
|
1294
|
+
deadline: a.datetime(),
|
|
1295
|
+
state: a.string().required(),
|
|
1296
|
+
answer: a.json(),
|
|
1297
|
+
links: a.json(),
|
|
1298
|
+
history: a.json(),
|
|
1299
|
+
appliedIn: a.json(),
|
|
1300
|
+
updatedAt: a.datetime().required(),
|
|
1301
|
+
})
|
|
1302
|
+
.authorization((allow) => [
|
|
1303
|
+
allow.group("editor").to(["read", "create", "update"]),
|
|
1304
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1305
|
+
]),
|
|
1306
|
+
|
|
1307
|
+
/** One user's read mark on one review request; id is "<userKey>#<requestId>". */
|
|
1308
|
+
ReviewReadMark: a
|
|
1309
|
+
.model({
|
|
1310
|
+
id: a.id().required(),
|
|
1311
|
+
requestId: a.string().required(),
|
|
1312
|
+
userKey: a.string().required(),
|
|
1313
|
+
readAt: a.datetime().required(),
|
|
1314
|
+
})
|
|
1315
|
+
.authorization((allow) => [
|
|
1316
|
+
allow.groups(["editor", adminGroup]).to(["read", "create", "update"]),
|
|
1317
|
+
]),
|
|
1318
|
+
|
|
1319
|
+
/** Who is told about review requests: scope default, mode or automation. Changed by data, not code. */
|
|
1320
|
+
ReviewRecipientRule: a
|
|
1321
|
+
.model({
|
|
1322
|
+
id: a.id().required(),
|
|
1323
|
+
scope: a.string().required(),
|
|
1324
|
+
key: a.string(),
|
|
1325
|
+
roles: a.string().array(),
|
|
1326
|
+
emails: a.string().array(),
|
|
1327
|
+
updatedBy: a.string(),
|
|
1328
|
+
updatedAt: a.datetime().required(),
|
|
1329
|
+
})
|
|
1330
|
+
.authorization((allow) => [
|
|
1331
|
+
allow.group("editor").to(["read"]),
|
|
1332
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1333
|
+
]),
|
|
1334
|
+
|
|
1282
1335
|
NewsroomSection: a
|
|
1283
1336
|
.model({
|
|
1284
1337
|
id: a.id().required(),
|
package/amplify/data/schema.js
CHANGED
|
@@ -1072,6 +1072,8 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
1072
1072
|
error: a.json(),
|
|
1073
1073
|
attempt: a.integer(),
|
|
1074
1074
|
newsroomFeedKey: a.string(),
|
|
1075
|
+
usage: a.json(),
|
|
1076
|
+
costUsd: a.float(),
|
|
1075
1077
|
})
|
|
1076
1078
|
.secondaryIndexes((index) => [
|
|
1077
1079
|
index("procedureId").sortKeys(["requestedAt"]).queryField("listProcedureRunsByProcedureAndRequestedAt"),
|
|
@@ -1102,6 +1104,51 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
1102
1104
|
allow.group("editor").to(["read", "create", "update"]),
|
|
1103
1105
|
allow.group(adminGroup).to(authoringOperations),
|
|
1104
1106
|
]),
|
|
1107
|
+
/** A human review point raised by a step on any item (PPY-ab6a50). Review is not a story state. */
|
|
1108
|
+
ReviewRequest: modelWithoutSubscriptions({
|
|
1109
|
+
id: a.id().required(),
|
|
1110
|
+
subject: a.json().required(),
|
|
1111
|
+
mode: a.string().required(),
|
|
1112
|
+
reason: a.string().required(),
|
|
1113
|
+
ask: a.string(),
|
|
1114
|
+
raisedBy: a.string().required(),
|
|
1115
|
+
raisedAt: a.datetime().required(),
|
|
1116
|
+
deadline: a.datetime(),
|
|
1117
|
+
state: a.string().required(),
|
|
1118
|
+
answer: a.json(),
|
|
1119
|
+
links: a.json(),
|
|
1120
|
+
history: a.json(),
|
|
1121
|
+
appliedIn: a.json(),
|
|
1122
|
+
updatedAt: a.datetime().required(),
|
|
1123
|
+
})
|
|
1124
|
+
.authorization((allow) => [
|
|
1125
|
+
allow.group("editor").to(["read", "create", "update"]),
|
|
1126
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1127
|
+
]),
|
|
1128
|
+
/** One user's read mark on one review request; id is "<userKey>#<requestId>". */
|
|
1129
|
+
ReviewReadMark: modelWithoutSubscriptions({
|
|
1130
|
+
id: a.id().required(),
|
|
1131
|
+
requestId: a.string().required(),
|
|
1132
|
+
userKey: a.string().required(),
|
|
1133
|
+
readAt: a.datetime().required(),
|
|
1134
|
+
})
|
|
1135
|
+
.authorization((allow) => [
|
|
1136
|
+
allow.groups(["editor", adminGroup]).to(["read", "create", "update"]),
|
|
1137
|
+
]),
|
|
1138
|
+
/** Who is told about review requests: scope default, mode or automation. Changed by data, not code. */
|
|
1139
|
+
ReviewRecipientRule: modelWithoutSubscriptions({
|
|
1140
|
+
id: a.id().required(),
|
|
1141
|
+
scope: a.string().required(),
|
|
1142
|
+
key: a.string(),
|
|
1143
|
+
roles: a.string().array(),
|
|
1144
|
+
emails: a.string().array(),
|
|
1145
|
+
updatedBy: a.string(),
|
|
1146
|
+
updatedAt: a.datetime().required(),
|
|
1147
|
+
})
|
|
1148
|
+
.authorization((allow) => [
|
|
1149
|
+
allow.group("editor").to(["read"]),
|
|
1150
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1151
|
+
]),
|
|
1105
1152
|
NewsroomSection: modelWithoutSubscriptions({
|
|
1106
1153
|
id: a.id().required(),
|
|
1107
1154
|
tenantId: a.string(),
|
package/amplify/data/schema.ts
CHANGED
|
@@ -1165,6 +1165,8 @@ const definitions = {
|
|
|
1165
1165
|
error: a.json(),
|
|
1166
1166
|
attempt: a.integer(),
|
|
1167
1167
|
newsroomFeedKey: a.string(),
|
|
1168
|
+
usage: a.json(),
|
|
1169
|
+
costUsd: a.float(),
|
|
1168
1170
|
})
|
|
1169
1171
|
.secondaryIndexes((index) => [
|
|
1170
1172
|
index("procedureId").sortKeys(["requestedAt"]).queryField("listProcedureRunsByProcedureAndRequestedAt"),
|
|
@@ -1197,6 +1199,54 @@ const definitions = {
|
|
|
1197
1199
|
allow.group(adminGroup).to(authoringOperations),
|
|
1198
1200
|
]),
|
|
1199
1201
|
|
|
1202
|
+
/** A human review point raised by a step on any item (PPY-ab6a50). Review is not a story state. */
|
|
1203
|
+
ReviewRequest: modelWithoutSubscriptions({
|
|
1204
|
+
id: a.id().required(),
|
|
1205
|
+
subject: a.json().required(),
|
|
1206
|
+
mode: a.string().required(),
|
|
1207
|
+
reason: a.string().required(),
|
|
1208
|
+
ask: a.string(),
|
|
1209
|
+
raisedBy: a.string().required(),
|
|
1210
|
+
raisedAt: a.datetime().required(),
|
|
1211
|
+
deadline: a.datetime(),
|
|
1212
|
+
state: a.string().required(),
|
|
1213
|
+
answer: a.json(),
|
|
1214
|
+
links: a.json(),
|
|
1215
|
+
history: a.json(),
|
|
1216
|
+
appliedIn: a.json(),
|
|
1217
|
+
updatedAt: a.datetime().required(),
|
|
1218
|
+
})
|
|
1219
|
+
.authorization((allow) => [
|
|
1220
|
+
allow.group("editor").to(["read", "create", "update"]),
|
|
1221
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1222
|
+
]),
|
|
1223
|
+
|
|
1224
|
+
/** One user's read mark on one review request; id is "<userKey>#<requestId>". */
|
|
1225
|
+
ReviewReadMark: modelWithoutSubscriptions({
|
|
1226
|
+
id: a.id().required(),
|
|
1227
|
+
requestId: a.string().required(),
|
|
1228
|
+
userKey: a.string().required(),
|
|
1229
|
+
readAt: a.datetime().required(),
|
|
1230
|
+
})
|
|
1231
|
+
.authorization((allow) => [
|
|
1232
|
+
allow.groups(["editor", adminGroup]).to(["read", "create", "update"]),
|
|
1233
|
+
]),
|
|
1234
|
+
|
|
1235
|
+
/** Who is told about review requests: scope default, mode or automation. Changed by data, not code. */
|
|
1236
|
+
ReviewRecipientRule: modelWithoutSubscriptions({
|
|
1237
|
+
id: a.id().required(),
|
|
1238
|
+
scope: a.string().required(),
|
|
1239
|
+
key: a.string(),
|
|
1240
|
+
roles: a.string().array(),
|
|
1241
|
+
emails: a.string().array(),
|
|
1242
|
+
updatedBy: a.string(),
|
|
1243
|
+
updatedAt: a.datetime().required(),
|
|
1244
|
+
})
|
|
1245
|
+
.authorization((allow) => [
|
|
1246
|
+
allow.group("editor").to(["read"]),
|
|
1247
|
+
allow.group(adminGroup).to(authoringOperations),
|
|
1248
|
+
]),
|
|
1249
|
+
|
|
1200
1250
|
NewsroomSection: modelWithoutSubscriptions({
|
|
1201
1251
|
id: a.id().required(),
|
|
1202
1252
|
tenantId: a.string(),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The automation-view function: `automationOverview`
|
|
1
|
+
"""The automation-view function: `automationOverview` (editors, admins and operator IAM roles, read only) and `runAutomation` (editors and admins)."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -7,5 +7,11 @@ from typing import Any
|
|
|
7
7
|
from papyrus_content.automation_view import handle
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
|
|
10
|
+
def _account(context: Any) -> str | None:
|
|
11
|
+
arn = getattr(context, "invoked_function_arn", None)
|
|
12
|
+
parts = str(arn or "").split(":")
|
|
13
|
+
return parts[4] if len(parts) > 4 and parts[4] else None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handler(event: dict[str, Any], context: Any = None) -> dict[str, Any]:
|
|
17
|
+
return handle(event, account_id=_account(context))
|
|
@@ -3,8 +3,11 @@ import { automationView } from "./resource.js";
|
|
|
3
3
|
const editors = ["editor", "admin"];
|
|
4
4
|
/**
|
|
5
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
|
|
7
|
-
* and admins
|
|
6
|
+
* trigger rows they link to, and the procedure runs through `automationOverview`, and starts a run
|
|
7
|
+
* through `runAutomation`. Both admit editors and admins. AppSync also lets an IAM principal call the
|
|
8
|
+
* query when its own policy allows the field (every operation in this schema accepts IAM callers for
|
|
9
|
+
* the functions that call it); the function reads only for an IAM principal of its own account and refuses
|
|
10
|
+
* every IAM caller on `runAutomation`.
|
|
8
11
|
*/
|
|
9
12
|
export const automationViewSchemaDefinitions = {
|
|
10
13
|
automationOverview: a
|
|
@@ -13,4 +16,10 @@ export const automationViewSchemaDefinitions = {
|
|
|
13
16
|
.returns(a.json())
|
|
14
17
|
.authorization((allow) => [allow.groups(editors)])
|
|
15
18
|
.handler(a.handler.function(automationView)),
|
|
19
|
+
runAutomation: a
|
|
20
|
+
.mutation()
|
|
21
|
+
.arguments({ automationId: a.string().required(), input: a.json() })
|
|
22
|
+
.returns(a.json())
|
|
23
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
24
|
+
.handler(a.handler.function(automationView)),
|
|
16
25
|
};
|
|
@@ -5,8 +5,11 @@ const editors = ["editor", "admin"];
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
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
|
|
9
|
-
* and admins
|
|
8
|
+
* trigger rows they link to, and the procedure runs through `automationOverview`, and starts a run
|
|
9
|
+
* through `runAutomation`. Both admit editors and admins. AppSync also lets an IAM principal call the
|
|
10
|
+
* query when its own policy allows the field (every operation in this schema accepts IAM callers for
|
|
11
|
+
* the functions that call it); the function reads only for an IAM principal of its own account and refuses
|
|
12
|
+
* every IAM caller on `runAutomation`.
|
|
10
13
|
*/
|
|
11
14
|
export const automationViewSchemaDefinitions = {
|
|
12
15
|
automationOverview: a
|
|
@@ -15,4 +18,10 @@ export const automationViewSchemaDefinitions = {
|
|
|
15
18
|
.returns(a.json())
|
|
16
19
|
.authorization((allow) => [allow.groups(editors)])
|
|
17
20
|
.handler(a.handler.function(automationView)),
|
|
21
|
+
runAutomation: a
|
|
22
|
+
.mutation()
|
|
23
|
+
.arguments({ automationId: a.string().required(), input: a.json() })
|
|
24
|
+
.returns(a.json())
|
|
25
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
26
|
+
.handler(a.handler.function(automationView)),
|
|
18
27
|
};
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { Stack } from "aws-cdk-lib";
|
|
2
2
|
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import { MONITORING_TABLE_PARAMETER, MONITORING_VIEW_TABLE_ACTIONS, monitoringViewParameterArn, monitoringViewTableArns, } from "../monitoring-view/wire.js";
|
|
4
|
-
/** The
|
|
5
|
-
export const AUTOMATION_VIEW_FUNCTION_FIELDS = [
|
|
4
|
+
/** The reads the function makes of its own site's API with its own role. It writes nothing there. */
|
|
5
|
+
export const AUTOMATION_VIEW_FUNCTION_FIELDS = [
|
|
6
|
+
"Query/fields/getAutomation",
|
|
7
|
+
"Query/fields/getNewsroomProcedureDefinition",
|
|
8
|
+
"Query/fields/getProcedureRun",
|
|
9
|
+
"Query/fields/listAutomations",
|
|
10
|
+
"Query/fields/listProcedureRunsByProcedureAndRequestedAt",
|
|
11
|
+
];
|
|
12
|
+
/** The procedure runner stack's function (infra/procedures/papyrus-procedure-runner.yaml); Run now starts it asynchronously. */
|
|
13
|
+
export const PROCEDURE_RUNNER_FUNCTION_NAME = "papyrus-procedure-runner";
|
|
14
|
+
/** Run now writes one row of its own to the monitoring table, in a monitor's partition (`MONITOR#<id>`), and nothing else. */
|
|
15
|
+
export const AUTOMATION_RUN_NOW_TABLE_ACTIONS = ["dynamodb:PutItem"];
|
|
16
|
+
export const AUTOMATION_RUN_NOW_LEADING_KEYS = ["MONITOR#*"];
|
|
6
17
|
/**
|
|
7
|
-
* Environment and least-privilege IAM for the automation-view function:
|
|
8
|
-
*
|
|
18
|
+
* Environment and least-privilege IAM for the automation-view function: a few read fields of the site's own API,
|
|
19
|
+
* the same read-only grant on the monitoring table that the Monitoring page has, one PutItem for Run now's row,
|
|
20
|
+
* and permission to start the procedure runner. A site outside the
|
|
9
21
|
* monitoring account finds no parameter and the page lists its records without runs.
|
|
10
22
|
*/
|
|
11
23
|
export function wireAutomationView(backend) {
|
|
@@ -13,6 +25,7 @@ export function wireAutomationView(backend) {
|
|
|
13
25
|
const stack = Stack.of(lambda);
|
|
14
26
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
15
27
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
28
|
+
lambda.addEnvironment("PROCEDURE_RUNNER_FUNCTION", PROCEDURE_RUNNER_FUNCTION_NAME);
|
|
16
29
|
const apiId = backend.data.resources.graphqlApi.apiId;
|
|
17
30
|
lambda.addToRolePolicy(new PolicyStatement({
|
|
18
31
|
effect: Effect.ALLOW,
|
|
@@ -29,4 +42,15 @@ export function wireAutomationView(backend) {
|
|
|
29
42
|
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
30
43
|
resources: monitoringViewTableArns(stack.region, stack.account),
|
|
31
44
|
}));
|
|
45
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
46
|
+
effect: Effect.ALLOW,
|
|
47
|
+
actions: AUTOMATION_RUN_NOW_TABLE_ACTIONS,
|
|
48
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
49
|
+
conditions: { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": AUTOMATION_RUN_NOW_LEADING_KEYS } },
|
|
50
|
+
}));
|
|
51
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
52
|
+
effect: Effect.ALLOW,
|
|
53
|
+
actions: ["lambda:InvokeFunction"],
|
|
54
|
+
resources: [`arn:aws:lambda:${stack.region}:${stack.account}:function:${PROCEDURE_RUNNER_FUNCTION_NAME}`],
|
|
55
|
+
}));
|
|
32
56
|
}
|
|
@@ -8,12 +8,26 @@ import {
|
|
|
8
8
|
monitoringViewTableArns,
|
|
9
9
|
} from "../monitoring-view/wire";
|
|
10
10
|
|
|
11
|
-
/** The
|
|
12
|
-
export const AUTOMATION_VIEW_FUNCTION_FIELDS = [
|
|
11
|
+
/** The reads the function makes of its own site's API with its own role. It writes nothing there. */
|
|
12
|
+
export const AUTOMATION_VIEW_FUNCTION_FIELDS = [
|
|
13
|
+
"Query/fields/getAutomation",
|
|
14
|
+
"Query/fields/getNewsroomProcedureDefinition",
|
|
15
|
+
"Query/fields/getProcedureRun",
|
|
16
|
+
"Query/fields/listAutomations",
|
|
17
|
+
"Query/fields/listProcedureRunsByProcedureAndRequestedAt",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** The procedure runner stack's function (infra/procedures/papyrus-procedure-runner.yaml); Run now starts it asynchronously. */
|
|
21
|
+
export const PROCEDURE_RUNNER_FUNCTION_NAME = "papyrus-procedure-runner";
|
|
22
|
+
|
|
23
|
+
/** Run now writes one row of its own to the monitoring table, in a monitor's partition (`MONITOR#<id>`), and nothing else. */
|
|
24
|
+
export const AUTOMATION_RUN_NOW_TABLE_ACTIONS = ["dynamodb:PutItem"];
|
|
25
|
+
export const AUTOMATION_RUN_NOW_LEADING_KEYS = ["MONITOR#*"];
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
|
-
* Environment and least-privilege IAM for the automation-view function:
|
|
16
|
-
*
|
|
28
|
+
* Environment and least-privilege IAM for the automation-view function: a few read fields of the site's own API,
|
|
29
|
+
* the same read-only grant on the monitoring table that the Monitoring page has, one PutItem for Run now's row,
|
|
30
|
+
* and permission to start the procedure runner. A site outside the
|
|
17
31
|
* monitoring account finds no parameter and the page lists its records without runs.
|
|
18
32
|
*/
|
|
19
33
|
export function wireAutomationView(backend: any): void {
|
|
@@ -21,6 +35,7 @@ export function wireAutomationView(backend: any): void {
|
|
|
21
35
|
const stack = Stack.of(lambda);
|
|
22
36
|
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
|
|
23
37
|
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
38
|
+
lambda.addEnvironment("PROCEDURE_RUNNER_FUNCTION", PROCEDURE_RUNNER_FUNCTION_NAME);
|
|
24
39
|
const apiId = backend.data.resources.graphqlApi.apiId as string;
|
|
25
40
|
lambda.addToRolePolicy(
|
|
26
41
|
new PolicyStatement({
|
|
@@ -43,4 +58,19 @@ export function wireAutomationView(backend: any): void {
|
|
|
43
58
|
resources: monitoringViewTableArns(stack.region, stack.account),
|
|
44
59
|
}),
|
|
45
60
|
);
|
|
61
|
+
lambda.addToRolePolicy(
|
|
62
|
+
new PolicyStatement({
|
|
63
|
+
effect: Effect.ALLOW,
|
|
64
|
+
actions: AUTOMATION_RUN_NOW_TABLE_ACTIONS,
|
|
65
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
66
|
+
conditions: { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": AUTOMATION_RUN_NOW_LEADING_KEYS } },
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
lambda.addToRolePolicy(
|
|
70
|
+
new PolicyStatement({
|
|
71
|
+
effect: Effect.ALLOW,
|
|
72
|
+
actions: ["lambda:InvokeFunction"],
|
|
73
|
+
resources: [`arn:aws:lambda:${stack.region}:${stack.account}:function:${PROCEDURE_RUNNER_FUNCTION_NAME}`],
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
46
76
|
}
|
|
@@ -7,7 +7,7 @@ export const AI_MODEL_NAME = /^[a-z0-9][a-z0-9.-]{0,63}$/;
|
|
|
7
7
|
export const SUBMISSION_KIND_NAME = /^[a-z][a-z0-9-]{0,23}$/;
|
|
8
8
|
export const SUBJECT_TYPES = ["item", "submission", "story", "proposal", "concept"];
|
|
9
9
|
export const COMPUTE_ROLE_ARN = /^arn:aws:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,512}$/;
|
|
10
|
-
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
|
|
10
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
|
|
11
11
|
function object(raw, label, known) {
|
|
12
12
|
if (raw === undefined)
|
|
13
13
|
return {};
|
|
@@ -117,6 +117,7 @@ export function resolveReaderSteeringConfig(request) {
|
|
|
117
117
|
if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => SUBJECT_TYPES.includes(type))) {
|
|
118
118
|
throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
|
|
119
119
|
}
|
|
120
|
+
const proposals = object(raw.proposals, "proposals", ["publish"]);
|
|
120
121
|
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
121
122
|
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
122
123
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
@@ -173,6 +174,7 @@ export function resolveReaderSteeringConfig(request) {
|
|
|
173
174
|
maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
|
|
174
175
|
maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
|
|
175
176
|
},
|
|
177
|
+
proposals: { publish: flag(proposals.publish, "proposals.publish") },
|
|
176
178
|
retirementPoll,
|
|
177
179
|
...(hypeGap ? { hypeGap } : {}),
|
|
178
180
|
...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
|
|
@@ -98,6 +98,14 @@ export type ReaderSteeringConfig = {
|
|
|
98
98
|
/** Most links (http, https or www) in a comment. */
|
|
99
99
|
maximumLinks: number;
|
|
100
100
|
};
|
|
101
|
+
proposals: {
|
|
102
|
+
/**
|
|
103
|
+
* Publish every proposal, in every state (drafts included), as public `_data/proposals/` files in each snapshot run.
|
|
104
|
+
* Off by default: proposals stay private records until a site opts in. Turning it off stops further updates; it does not
|
|
105
|
+
* delete files already published.
|
|
106
|
+
*/
|
|
107
|
+
publish: boolean;
|
|
108
|
+
};
|
|
101
109
|
/**
|
|
102
110
|
* The hype gap is `mean(plus dimensions) - mean(minus dimensions)`, computed for reader means and for editor scores.
|
|
103
111
|
* Absent means no hype gap is published. The site names the dimensions in each group.
|
|
@@ -115,7 +123,7 @@ export type ReaderSteeringConfig = {
|
|
|
115
123
|
retirementPoll: boolean;
|
|
116
124
|
};
|
|
117
125
|
|
|
118
|
-
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects"]);
|
|
126
|
+
const KNOWN_KEYS = new Set(["name", "computeRoleArn", "dimensions", "limits", "reservedHandles", "karma", "ranking", "snapshot", "retirementPoll", "reservedConcurrency", "hypeGap", "submissions", "comments", "moderation", "subjects", "proposals"]);
|
|
119
127
|
|
|
120
128
|
function object(raw: unknown, label: string, known: string[]): Record<string, unknown> {
|
|
121
129
|
if (raw === undefined) return {};
|
|
@@ -217,6 +225,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
217
225
|
if (!Array.isArray(subjectTypes) || subjectTypes.length === 0 || !subjectTypes.every((type) => (SUBJECT_TYPES as readonly unknown[]).includes(type))) {
|
|
218
226
|
throw new Error(`reader-steering plugin subjects.types must be a non-empty list of: ${SUBJECT_TYPES.join(", ")}`);
|
|
219
227
|
}
|
|
228
|
+
const proposals = object(raw.proposals, "proposals", ["publish"]);
|
|
220
229
|
const kinds = names(submissions.kinds, "submissions.kinds", SUBMISSION_KIND_NAME);
|
|
221
230
|
const comments = object(raw.comments, "comments", ["perHour", "editWindowMinutes", "maximumLength", "maximumLinks"]);
|
|
222
231
|
const hypeGap = resolveHypeGap(raw.hypeGap, dimensions);
|
|
@@ -273,6 +282,7 @@ export function resolveReaderSteeringConfig(request: PluginRequest): ReaderSteer
|
|
|
273
282
|
maximumLength: integer(comments.maximumLength, "comments.maximumLength", 2000, 1, 10000),
|
|
274
283
|
maximumLinks: integer(comments.maximumLinks, "comments.maximumLinks", 3, 0, 50),
|
|
275
284
|
},
|
|
285
|
+
proposals: { publish: flag(proposals.publish, "proposals.publish") },
|
|
276
286
|
retirementPoll,
|
|
277
287
|
...(hypeGap ? { hypeGap } : {}),
|
|
278
288
|
...(raw.reservedConcurrency === undefined ? {} : { reservedConcurrency: integer(raw.reservedConcurrency, "reservedConcurrency", 1, 1, 1000) }),
|
|
@@ -159,6 +159,8 @@ export const readerSteeringPlugin = {
|
|
|
159
159
|
lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
|
|
160
160
|
lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
161
161
|
contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
|
|
162
|
+
// Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
|
|
163
|
+
contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
|
|
162
164
|
if (config.moderation.ai)
|
|
163
165
|
wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
|
|
164
166
|
lambda.addPermission("InvokeFromCmsCompute", {
|
|
@@ -182,6 +182,8 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
|
|
|
182
182
|
lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
|
|
183
183
|
lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
184
184
|
contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
|
|
185
|
+
// Proposals are private unless the site opts in (docs/reader-steering.md, Proposals); the snapshot reads this flag.
|
|
186
|
+
contentActions.addEnvironment("PAPYRUS_READER_PUBLISH_PROPOSALS", config.proposals.publish ? "true" : "false");
|
|
185
187
|
|
|
186
188
|
if (config.moderation.ai) wireAiModeration(lambda, contentActions, schedulerRole, config.moderation.ai.apiKeyParameter);
|
|
187
189
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import { loadAutomationOverview } from "../lib/automation-client";
|
|
4
|
+
import { loadAutomationOverview, runAutomationNow } from "../lib/automation-client";
|
|
5
5
|
import { FIXTURE_AUTOMATION_OVERVIEW } from "../lib/automation-fixture";
|
|
6
6
|
import type { AutomationResult } from "../lib/automation-overview";
|
|
7
7
|
import { AutomationView } from "./automation-view";
|
|
@@ -35,5 +35,15 @@ export function AutomationPanel({ isDemo }: { isDemo: boolean }) {
|
|
|
35
35
|
};
|
|
36
36
|
}, [isDemo]);
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
const runNow = isDemo ? undefined : async (automationId: string, input: Record<string, unknown> | null) => {
|
|
39
|
+
const outcome = await runAutomationNow(automationId, input);
|
|
40
|
+
try {
|
|
41
|
+
setResult(await loadAutomationOverview()); // show the new run without waiting for the minute
|
|
42
|
+
} catch {
|
|
43
|
+
/* the next refresh will */
|
|
44
|
+
}
|
|
45
|
+
return outcome;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return <AutomationView result={result} selectedId={selectedId} onSelect={setSelectedId} demo={isDemo} onRunNow={runNow} />;
|
|
39
49
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
atATimeText,
|
|
6
|
+
inputTemplateText,
|
|
7
|
+
parseRunInput,
|
|
8
|
+
runNowMessage,
|
|
9
|
+
type AutomationRecord,
|
|
10
|
+
type RunNowResult,
|
|
11
|
+
} from "../lib/automation-overview";
|
|
12
|
+
|
|
13
|
+
export type RunNowPhase = "idle" | "confirming" | "running" | "done";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* "Run now": a button, then a confirm step that says what will start and, when the record's input names
|
|
17
|
+
* placeholders for an item, asks for the input of this run. Starting costs money (the procedure calls a model),
|
|
18
|
+
* so nothing starts on the first click. `initialPhase` and `initialMessage` exist so every step renders from a test.
|
|
19
|
+
*/
|
|
20
|
+
export function RunNowControl({
|
|
21
|
+
record,
|
|
22
|
+
demo = false,
|
|
23
|
+
onRun,
|
|
24
|
+
initialPhase = "idle",
|
|
25
|
+
initialMessage = null,
|
|
26
|
+
}: {
|
|
27
|
+
record: Pick<AutomationRecord, "id" | "title" | "procedureKey" | "runNow" | "limits">;
|
|
28
|
+
demo?: boolean;
|
|
29
|
+
onRun?: (automationId: string, input: Record<string, unknown> | null) => Promise<RunNowResult>;
|
|
30
|
+
initialPhase?: RunNowPhase;
|
|
31
|
+
initialMessage?: string | null;
|
|
32
|
+
}) {
|
|
33
|
+
const [phase, setPhase] = useState<RunNowPhase>(initialPhase);
|
|
34
|
+
const [message, setMessage] = useState<string | null>(initialMessage);
|
|
35
|
+
const [text, setText] = useState(record.runNow.needsInput ? inputTemplateText(record.runNow.inputNames) : "");
|
|
36
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
if (!record.runNow.available) {
|
|
39
|
+
return (
|
|
40
|
+
<p className="news-desk-delegation-note" data-automation-run-now-unavailable>
|
|
41
|
+
Run now is not available: {record.runNow.reason === "disabled" ? "the automation is disabled. Enable it first." : (record.runNow.reason ?? "not supported")}.
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function confirm() {
|
|
47
|
+
const parsed = parseRunInput(text);
|
|
48
|
+
if (!parsed.ok) {
|
|
49
|
+
setProblem(parsed.message);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (record.runNow.needsInput && parsed.value === null) {
|
|
53
|
+
setProblem("This automation's input names an item, so give the input for this run.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setProblem(null);
|
|
57
|
+
setPhase("running");
|
|
58
|
+
try {
|
|
59
|
+
const result = onRun ? await onRun(record.id, parsed.value) : ({ state: "unavailable" } as RunNowResult);
|
|
60
|
+
setMessage(runNowMessage(result));
|
|
61
|
+
} catch {
|
|
62
|
+
setMessage(runNowMessage({ state: "unavailable" }));
|
|
63
|
+
}
|
|
64
|
+
setPhase("done");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div data-automation-run-now={record.id} data-run-now-phase={phase}>
|
|
69
|
+
{phase === "idle" || phase === "done" ? (
|
|
70
|
+
<button type="button" data-automation-run-now-start onClick={() => { setMessage(null); setPhase("confirming"); }}>
|
|
71
|
+
Run now
|
|
72
|
+
</button>
|
|
73
|
+
) : null}
|
|
74
|
+
{phase === "confirming" || phase === "running" ? (
|
|
75
|
+
<div role="group" aria-label={`Confirm Run now for ${record.title}`} data-automation-run-now-confirm>
|
|
76
|
+
<p>
|
|
77
|
+
Run <strong>{record.procedureKey}</strong> now? It calls a model, which costs money, and it counts against the
|
|
78
|
+
limit ({atATimeText(record.limits)}). The run is linked to this automation.
|
|
79
|
+
</p>
|
|
80
|
+
{demo ? <p className="news-desk-delegation-note">This is the demo, so nothing will start.</p> : null}
|
|
81
|
+
<label>
|
|
82
|
+
Input for this run{record.runNow.needsInput ? " (required)" : " (optional; blank uses the record's input)"}
|
|
83
|
+
<textarea rows={4} value={text} onChange={(event) => setText(event.target.value)} disabled={phase === "running"} data-automation-run-now-input />
|
|
84
|
+
</label>
|
|
85
|
+
{problem ? <p role="alert" data-automation-run-now-problem>{problem}</p> : null}
|
|
86
|
+
<button type="button" onClick={() => void confirm()} disabled={phase === "running" || demo} data-automation-run-now-confirm-button>
|
|
87
|
+
{phase === "running" ? "Starting…" : "Confirm: start the run"}
|
|
88
|
+
</button>{" "}
|
|
89
|
+
<button type="button" onClick={() => { setPhase("idle"); setProblem(null); }} disabled={phase === "running"} data-automation-run-now-cancel>
|
|
90
|
+
Cancel
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
) : null}
|
|
94
|
+
{message ? <p role="status" data-automation-run-now-message>{message}</p> : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|