create-safest-tools 0.6.0 → 0.6.1
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/README.md +2 -2
- package/package.json +1 -1
- package/src/update.mjs +10 -1
- package/template/README.md +1 -1
- package/template/console/README.md +2 -2
- package/template/console/command/CommandCentre.tsx +94 -63
- package/template/console/command/controller.ts +71 -65
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +34 -0
- package/template/console/command/store.ts +3 -4
- package/template/console/command/types.ts +25 -21
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +22 -23
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/ReportDrawer.tsx +19 -6
- package/template/console/reports/ReportWorkspace.tsx +104 -14
- package/template/console/reports/controller.ts +31 -13
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/store.ts +1 -1
- package/template/console/reports/types.ts +9 -0
- package/template/console/shell/controller.ts +1 -8
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/package.json +8 -7
- package/template/public/console/auth-shell.js +9353 -8950
- package/template/public/styles.css +271 -74
- package/template/src/component-executor.ts +2 -2
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +9 -0
- package/template/src/index.ts +13 -11
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +20 -26
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email.ts +7 -30
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +15 -5
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-workflow.ts +34 -7
- package/template/src/workflow-dynamic-runtime.ts +2 -2
- package/template/src/workflow-effects.ts +22 -21
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-runs.ts +52 -9
|
@@ -1,49 +1,54 @@
|
|
|
1
1
|
import type { WorkspaceView } from "../shell/types";
|
|
2
2
|
|
|
3
|
-
export type CommandStatusTone = "neutral" | "warning" | "danger";
|
|
3
|
+
export type CommandStatusTone = "neutral" | "positive" | "warning" | "danger";
|
|
4
4
|
|
|
5
5
|
export type CommandAction =
|
|
6
6
|
| { type: "report"; reportId: string }
|
|
7
7
|
| { type: "view"; view: WorkspaceView };
|
|
8
8
|
|
|
9
9
|
export interface CommandMetricSnapshot {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
awaitingReview: number;
|
|
11
|
+
urgent: number;
|
|
12
|
+
unassigned: number;
|
|
13
|
+
onlineReviewers: number;
|
|
14
|
+
oldestWaitMinutes: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface CommandAttentionItem {
|
|
17
18
|
id: string;
|
|
18
|
-
icon: string;
|
|
19
19
|
title: string;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
reference: string;
|
|
21
|
+
queueName: string;
|
|
22
|
+
age: string;
|
|
23
|
+
ownership: string;
|
|
24
|
+
priorityLabel: string;
|
|
25
|
+
slaLabel: string;
|
|
26
|
+
tone: CommandStatusTone;
|
|
23
27
|
action: CommandAction;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface CommandQueueLoad {
|
|
27
31
|
id: string;
|
|
28
32
|
name: string;
|
|
29
|
-
targetMinutes: number;
|
|
33
|
+
targetMinutes: number | null;
|
|
30
34
|
openCount: number;
|
|
35
|
+
unassignedCount: number;
|
|
36
|
+
oldestMinutes: number;
|
|
37
|
+
breachedCount: number;
|
|
31
38
|
pressure: number;
|
|
39
|
+
tone: CommandStatusTone;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
export interface
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
age: string;
|
|
41
|
-
action: CommandAction;
|
|
42
|
+
export interface CommandSystemHealth {
|
|
43
|
+
issueCount: number;
|
|
44
|
+
incidentCount: number;
|
|
45
|
+
workflowIssueCount: number;
|
|
46
|
+
deliveryFailureCount: number;
|
|
47
|
+
activeRunCount: number;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export interface CommandPermissions {
|
|
45
51
|
useAssistant: boolean;
|
|
46
|
-
manageWorkflows: boolean;
|
|
47
52
|
viewAudit: boolean;
|
|
48
53
|
manageInstallation: boolean;
|
|
49
54
|
}
|
|
@@ -51,11 +56,10 @@ export interface CommandPermissions {
|
|
|
51
56
|
export interface CommandSnapshot {
|
|
52
57
|
loading: boolean;
|
|
53
58
|
generatedAt: string;
|
|
54
|
-
firstName: string;
|
|
55
59
|
metrics: CommandMetricSnapshot;
|
|
56
60
|
attention: CommandAttentionItem[];
|
|
57
61
|
queueLoads: CommandQueueLoad[];
|
|
58
|
-
|
|
62
|
+
systemHealth: CommandSystemHealth;
|
|
59
63
|
incompleteSetupCount: number;
|
|
60
64
|
permissions: CommandPermissions;
|
|
61
65
|
}
|
|
@@ -124,7 +124,7 @@ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChange
|
|
|
124
124
|
useEffect(() => {
|
|
125
125
|
if (!item) return;
|
|
126
126
|
let active = true;
|
|
127
|
-
setLoading(true); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
|
|
127
|
+
setLoading(true); setPending(""); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
|
|
128
128
|
void Promise.all([
|
|
129
129
|
requestJson<RegistryDetailResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}`),
|
|
130
130
|
requestJson<RegistryDependenciesResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}/dependencies`),
|
|
@@ -4,20 +4,21 @@ import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
|
4
4
|
import { announceRegistryChanged, registryEvents } from "./events";
|
|
5
5
|
import type { ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
|
|
6
6
|
|
|
7
|
-
type ToolPurpose = "enrichment" | "action" | "ai"
|
|
8
|
-
type ComponentKind = "
|
|
9
|
-
type ImplementationKind = "
|
|
7
|
+
type ToolPurpose = "enrichment" | "action" | "ai";
|
|
8
|
+
type ComponentKind = "webhook_enrichment" | "api_enrichment" | "workers_ai" | "action";
|
|
9
|
+
type ImplementationKind = "webhook" | "external_api" | "workers_ai";
|
|
10
10
|
type ConnectionKind = "webhook" | "external_api" | "customer_worker" | "ai_provider" | "notification" | "email";
|
|
11
11
|
type CredentialStrategy = "none" | "static_header" | "bearer" | "hmac" | "service_binding";
|
|
12
12
|
|
|
13
13
|
const inputSchemaDefault = JSON.stringify({ type: "object", properties: { report: { type: "object" } } }, null, 2);
|
|
14
14
|
const outputSchemaDefault = JSON.stringify({ type: "object", properties: { result: { type: "string" } } }, null, 2);
|
|
15
|
+
const fixtureInputDefault = JSON.stringify({ report: {} }, null, 2);
|
|
16
|
+
const fixtureOutputDefault = JSON.stringify({ result: "ok" }, null, 2);
|
|
15
17
|
|
|
16
18
|
const purposeOptions: Array<{ value: ToolPurpose; title: string; description: string; meta: string; icon: string }> = [
|
|
17
19
|
{ value: "enrichment", title: "Enrichment", description: "Read customer context and add findings to a report.", meta: "Read-only", icon: "+" },
|
|
18
20
|
{ value: "action", title: "Action", description: "Apply a reviewed change in a customer system.", meta: "Approval required", icon: "⚡" },
|
|
19
21
|
{ value: "ai", title: "AI analysis", description: "Classify, summarize, or analyze report evidence.", meta: "Read-only", icon: "✦" },
|
|
20
|
-
{ value: "message", title: "Message", description: "Send a reusable, governed report communication.", meta: "Read-only", icon: "✉" },
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
function slug(value: string): string {
|
|
@@ -36,19 +37,15 @@ function jsonObject(value: string, label: string): Record<string, unknown> {
|
|
|
36
37
|
|
|
37
38
|
function componentForPurpose(purpose: ToolPurpose, implementation: ImplementationKind): ComponentKind {
|
|
38
39
|
if (purpose === "action") return "action";
|
|
39
|
-
if (purpose === "ai") return
|
|
40
|
-
if (purpose === "message") return "message";
|
|
40
|
+
if (purpose === "ai") return "workers_ai";
|
|
41
41
|
if (implementation === "webhook") return "webhook_enrichment";
|
|
42
|
-
|
|
43
|
-
return "built_in";
|
|
42
|
+
return "api_enrichment";
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
function referenceLabel(implementation: ImplementationKind): string {
|
|
47
46
|
if (["webhook", "external_api"].includes(implementation)) return "Operation";
|
|
48
47
|
if (implementation === "workers_ai") return "Workers AI model";
|
|
49
|
-
|
|
50
|
-
if (implementation === "message_template") return "Message template key";
|
|
51
|
-
return "Built-in handler";
|
|
48
|
+
return "Implementation";
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
export function RegistryDialog() {
|
|
@@ -64,14 +61,16 @@ export function RegistryDialog() {
|
|
|
64
61
|
const [keyEdited, setKeyEdited] = useState(false);
|
|
65
62
|
const [description, setDescription] = useState("");
|
|
66
63
|
const [purpose, setPurpose] = useState<ToolPurpose>("enrichment");
|
|
67
|
-
const [implementation, setImplementation] = useState<ImplementationKind>("
|
|
68
|
-
const [reference, setReference] = useState("
|
|
64
|
+
const [implementation, setImplementation] = useState<ImplementationKind>("external_api");
|
|
65
|
+
const [reference, setReference] = useState("POST /");
|
|
69
66
|
const [connectionVersionId, setConnectionVersionId] = useState("");
|
|
70
67
|
const [risk, setRisk] = useState("low");
|
|
71
68
|
const [inputFields, setInputFields] = useState("report.id, report.reason_code");
|
|
72
69
|
const [classifications, setClassifications] = useState("report_operational");
|
|
73
70
|
const [inputSchema, setInputSchema] = useState(inputSchemaDefault);
|
|
74
71
|
const [outputSchema, setOutputSchema] = useState(outputSchemaDefault);
|
|
72
|
+
const [fixtureInput, setFixtureInput] = useState(fixtureInputDefault);
|
|
73
|
+
const [fixtureOutput, setFixtureOutput] = useState(fixtureOutputDefault);
|
|
75
74
|
const [connections, setConnections] = useState<ConnectionSummary[]>([]);
|
|
76
75
|
const [connectionKind, setConnectionKind] = useState<ConnectionKind>("external_api");
|
|
77
76
|
const [targetMode, setTargetMode] = useState<"url" | "service">("url");
|
|
@@ -84,8 +83,9 @@ export function RegistryDialog() {
|
|
|
84
83
|
|
|
85
84
|
const reset = (): void => {
|
|
86
85
|
setStep(0); setName(""); setKey(""); setKeyEdited(false); setDescription(""); setPurpose("enrichment");
|
|
87
|
-
setImplementation("
|
|
86
|
+
setImplementation("external_api"); setReference("POST /"); setConnectionVersionId(""); setRisk("low");
|
|
88
87
|
setInputFields("report.id, report.reason_code"); setClassifications("report_operational"); setInputSchema(inputSchemaDefault); setOutputSchema(outputSchemaDefault);
|
|
88
|
+
setFixtureInput(fixtureInputDefault); setFixtureOutput(fixtureOutputDefault);
|
|
89
89
|
setConnectionKind("external_api"); setTargetMode("url"); setBaseUrl(""); setServiceBinding(""); setMethods("POST"); setPaths("/"); setCredential("none"); setSecretReference(""); setError("");
|
|
90
90
|
};
|
|
91
91
|
const close = (): void => { if (!pending) { setKind(null); reset(); } };
|
|
@@ -127,19 +127,15 @@ export function RegistryDialog() {
|
|
|
127
127
|
|
|
128
128
|
const selectPurpose = (next: ToolPurpose): void => {
|
|
129
129
|
setPurpose(next);
|
|
130
|
-
if (next === "enrichment") { setImplementation("
|
|
130
|
+
if (next === "enrichment") { setImplementation("external_api"); setReference("POST /"); setRisk("low"); }
|
|
131
131
|
if (next === "action") { setImplementation("external_api"); setReference("POST /"); setRisk("high"); }
|
|
132
132
|
if (next === "ai") { setImplementation("workers_ai"); setReference("@cf/meta/llama-3.1-8b-instruct"); setRisk("medium"); }
|
|
133
|
-
if (next === "message") { setImplementation("message_template"); setReference("report-update-v1"); setRisk("low"); }
|
|
134
133
|
};
|
|
135
134
|
|
|
136
135
|
const changeImplementation = (next: ImplementationKind): void => {
|
|
137
136
|
setImplementation(next); setConnectionVersionId("");
|
|
138
|
-
if (next === "built_in") setReference("custom-enrichment-v1");
|
|
139
137
|
if (["webhook", "external_api"].includes(next)) setReference("POST /");
|
|
140
138
|
if (next === "workers_ai") setReference("@cf/meta/llama-3.1-8b-instruct");
|
|
141
|
-
if (next === "specialist_agent") setReference("custom-specialist-v1");
|
|
142
|
-
if (next === "message_template") setReference("report-update-v1");
|
|
143
139
|
};
|
|
144
140
|
|
|
145
141
|
const changeTargetMode = (next: "url" | "service"): void => {
|
|
@@ -161,7 +157,10 @@ export function RegistryDialog() {
|
|
|
161
157
|
if (!reference.trim()) { setError(`Enter the ${referenceLabel(implementation).toLowerCase()}.`); return false; }
|
|
162
158
|
if (needsConnection && !connectionVersionId) { setError("Select the connection this tool should use."); return false; }
|
|
163
159
|
if (!commaValues(inputFields).length || !commaValues(classifications).length) { setError("Add at least one allowed input field and data classification."); return false; }
|
|
164
|
-
try {
|
|
160
|
+
try {
|
|
161
|
+
jsonObject(inputSchema, "Input schema"); jsonObject(outputSchema, "Output schema");
|
|
162
|
+
jsonObject(fixtureInput, "Example input"); jsonObject(fixtureOutput, "Example output");
|
|
163
|
+
} catch (cause) { setError(errorMessage(cause, "Fix the JSON contracts.")); return false; }
|
|
165
164
|
} else {
|
|
166
165
|
if (!commaValues(methods).length || !commaValues(paths).every((value) => value.startsWith("/"))) { setError("Allow at least one method and use absolute paths beginning with /."); return false; }
|
|
167
166
|
if (needsSecret && !/^(?:CONNECTION|OAUTH|ACTION|NOTIFICATION|EMAIL|AI)_[A-Z0-9_]{2,96}$/u.test(secretReference)) { setError("Enter a valid Worker secret binding name, not the secret value."); return false; }
|
|
@@ -202,7 +201,7 @@ export function RegistryDialog() {
|
|
|
202
201
|
timeout_policy: { timeout_ms: 5000 }, retry_policy: { maximum_attempts: isAction ? 5 : 3, backoff: "exponential" },
|
|
203
202
|
cache_policy: { enabled: !isAction }, concurrency_policy: { maximum_concurrency: 8 }, budget_policy: {},
|
|
204
203
|
approval_policy: isAction ? { mode: "human" } : {}, tool_manifest: [],
|
|
205
|
-
compatibility: { fixture_evidence: {
|
|
204
|
+
compatibility: { fixture_evidence: { fixtures: [{ name: "Contract example", input: jsonObject(fixtureInput, "Example input"), expected_output: jsonObject(fixtureOutput, "Example output"), expected_status: "succeeded", mode: "simulated" }] } },
|
|
206
205
|
},
|
|
207
206
|
}),
|
|
208
207
|
});
|
|
@@ -215,14 +214,14 @@ export function RegistryDialog() {
|
|
|
215
214
|
const componentSetup = <>
|
|
216
215
|
<div className={`safety-boundary ${isAction ? "registry-action-boundary" : ""}`}><strong>{isAction ? "Can change customer data" : "Read-only authority"}</strong><span>{isAction ? "Every workflow using this action must require human or bounded approval." : "This tool may add context and findings but cannot change customer data."}</span></div>
|
|
217
216
|
<div className="form-grid">
|
|
218
|
-
<label>How it runs<select value={implementation} onChange={(event) => changeImplementation(event.currentTarget.value as ImplementationKind)}>{purpose === "
|
|
217
|
+
<label>How it runs<select value={implementation} onChange={(event) => changeImplementation(event.currentTarget.value as ImplementationKind)}>{purpose === "ai" ? <option value="workers_ai">Workers AI</option> : <><option value="external_api">Connected API</option><option value="webhook">Connected webhook</option></>}</select><small>{needsConnection ? "Requests use a separately governed connection." : "Runs inside this Resolve deployment."}</small></label>
|
|
219
218
|
<label>{referenceLabel(implementation)}<input value={reference} onChange={(event) => setReference(event.currentTarget.value)} /><small>{needsConnection ? "For example: POST /v1/enrich/report" : "An immutable runtime reference."}</small></label>
|
|
220
219
|
{needsConnection ? <label className="wide-field">Connection<select value={connectionVersionId} onChange={(event) => setConnectionVersionId(event.currentTarget.value)}><option value="">Select a connection</option>{activeConnections.map((connection) => <option value={connection.activeVersionId || ""} key={connection.id}>{connection.name} · v{connection.version || 1}</option>)}</select>{!activeConnections.length ? <small>No active connections are available. Cancel this setup and add a connection first.</small> : <small>The tool pins this exact connection version for reproducibility.</small>}</label> : null}
|
|
221
220
|
<label>Risk level<select value={risk} onChange={(event) => setRisk(event.currentTarget.value)}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label>
|
|
222
221
|
<label>Data classifications<input value={classifications} onChange={(event) => setClassifications(event.currentTarget.value)} /><small>Comma-separated labels used for governance.</small></label>
|
|
223
222
|
<label className="wide-field">Allowed report fields<input value={inputFields} onChange={(event) => setInputFields(event.currentTarget.value)} /><small>Only these report fields are disclosed when the tool runs.</small></label>
|
|
224
223
|
</div>
|
|
225
|
-
<details className="registry-advanced"><summary>Advanced data contracts</summary><p>JSON schemas constrain what the workflow sends and what the tool may return.</p><div className="form-grid"><label className="wide-field">Input schema<textarea className="code-input" spellCheck={false} value={inputSchema} onChange={(event) => setInputSchema(event.currentTarget.value)} /></label><label className="wide-field">Output schema<textarea className="code-input" spellCheck={false} value={outputSchema} onChange={(event) => setOutputSchema(event.currentTarget.value)} /></label></div></details>
|
|
224
|
+
<details className="registry-advanced"><summary>Advanced data contracts</summary><p>JSON schemas constrain what the workflow sends and what the tool may return. The example must match both schemas and is checked when the tool is created.</p><div className="form-grid"><label className="wide-field">Input schema<textarea className="code-input" spellCheck={false} value={inputSchema} onChange={(event) => setInputSchema(event.currentTarget.value)} /></label><label className="wide-field">Example input<textarea className="code-input" spellCheck={false} value={fixtureInput} onChange={(event) => setFixtureInput(event.currentTarget.value)} /></label><label className="wide-field">Output schema<textarea className="code-input" spellCheck={false} value={outputSchema} onChange={(event) => setOutputSchema(event.currentTarget.value)} /></label><label className="wide-field">Example output<textarea className="code-input" spellCheck={false} value={fixtureOutput} onChange={(event) => setFixtureOutput(event.currentTarget.value)} /></label></div></details>
|
|
226
225
|
</>;
|
|
227
226
|
|
|
228
227
|
const connectionSecurity = <>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { dateTime, readable } from "../lib/format";
|
|
2
|
+
import type { FindingRecord, WorkflowRunRecord } from "./types";
|
|
3
|
+
|
|
4
|
+
type DataRecord = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
function record(value: unknown): DataRecord {
|
|
7
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as DataRecord : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function records(value: unknown): DataRecord[] {
|
|
11
|
+
return Array.isArray(value) ? value.filter((item): item is DataRecord => Boolean(item && typeof item === "object" && !Array.isArray(item))) : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function text(value: unknown, fallback = ""): string {
|
|
15
|
+
return typeof value === "string" || typeof value === "number" ? String(value) : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function count(value: unknown): number {
|
|
19
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function accountAge(value: unknown): string {
|
|
23
|
+
const createdAt = Date.parse(text(value));
|
|
24
|
+
if (!Number.isFinite(createdAt) || createdAt > Date.now()) return "Unavailable";
|
|
25
|
+
const created = new Date(createdAt);
|
|
26
|
+
const now = new Date();
|
|
27
|
+
let months = (now.getUTCFullYear() - created.getUTCFullYear()) * 12 + now.getUTCMonth() - created.getUTCMonth();
|
|
28
|
+
if (now.getUTCDate() < created.getUTCDate()) months -= 1;
|
|
29
|
+
if (months < 1) {
|
|
30
|
+
const days = Math.floor((now.getTime() - created.getTime()) / 86_400_000);
|
|
31
|
+
return days < 1 ? "Today" : `${days} day${days === 1 ? "" : "s"}`;
|
|
32
|
+
}
|
|
33
|
+
const years = Math.floor(months / 12);
|
|
34
|
+
const remainingMonths = months % 12;
|
|
35
|
+
if (!years) return `${months} month${months === 1 ? "" : "s"}`;
|
|
36
|
+
return remainingMonths ? `${years}y ${remainingMonths}m` : `${years} year${years === 1 ? "" : "s"}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findingTitle(finding: FindingRecord): string {
|
|
40
|
+
return readable(finding.componentKey || finding.findingType || "Enrichment result");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findingState(finding: FindingRecord): string {
|
|
44
|
+
return finding.freshness || finding.status || "available";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function MessageResult({ message }: { message: DataRecord }) {
|
|
48
|
+
const media = record(message.media);
|
|
49
|
+
const reported = message.is_reported_message === true;
|
|
50
|
+
const body = text(message.body) || text(media.description) || (Object.keys(media).length ? "Media attachment" : "No message text");
|
|
51
|
+
const author = text(message.author_name) || readable(text(message.author_role, "participant"));
|
|
52
|
+
const timestamp = text(message.created_at);
|
|
53
|
+
const moderation = text(message.moderation_reason);
|
|
54
|
+
return (
|
|
55
|
+
<article className={`enrichment-message${reported ? " reported" : ""}`}>
|
|
56
|
+
<header><div><strong>{reported ? "Reported message" : author}</strong><span>{reported ? author : readable(text(message.author_role, "participant"))}</span></div><span>{timestamp ? dateTime(timestamp) : "Time unavailable"}</span></header>
|
|
57
|
+
<p>{body}</p>
|
|
58
|
+
<small>{readable(text(message.status, "available"))}{moderation ? ` · ${moderation}` : ""}{text(media.verdict) ? ` · Media: ${readable(text(media.verdict))}` : ""}</small>
|
|
59
|
+
</article>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ConversationResult({ data }: { data: DataRecord }) {
|
|
64
|
+
const messages = records(data.messages);
|
|
65
|
+
if (!messages.length) return null;
|
|
66
|
+
const reported = messages.find((message) => message.is_reported_message === true);
|
|
67
|
+
const context = messages.filter((message) => message !== reported);
|
|
68
|
+
const preview = context.slice(Math.max(0, context.length - 5));
|
|
69
|
+
return (
|
|
70
|
+
<div className="enrichment-conversation">
|
|
71
|
+
{reported ? <div><h4>Reported message</h4><MessageResult message={reported} /></div> : null}
|
|
72
|
+
{preview.length ? <div><h4>{reported ? "Nearby conversation" : "Recent shared conversation"}</h4><div className="enrichment-message-list">{preview.map((message, index) => <MessageResult message={message} key={text(message.id, `message-${index}`)} />)}</div></div> : null}
|
|
73
|
+
{messages.length > preview.length + (reported ? 1 : 0) ? <details className="enrichment-expand"><summary>Review all {messages.length} collected messages</summary><div className="enrichment-message-list">{messages.map((message, index) => <MessageResult message={message} key={text(message.id, `all-message-${index}`)} />)}</div></details> : null}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function AccountResult({ account }: { account: DataRecord }) {
|
|
79
|
+
const totals = record(account.activity_totals);
|
|
80
|
+
const restrictions = record(account.active_restrictions);
|
|
81
|
+
const createdAt = text(account.created_at);
|
|
82
|
+
const restrictionParts = [
|
|
83
|
+
text(restrictions.banned_until) ? `Banned until ${dateTime(text(restrictions.banned_until))}` : "",
|
|
84
|
+
text(restrictions.posting_suspended_until) ? `Posting suspended until ${dateTime(text(restrictions.posting_suspended_until))}` : "",
|
|
85
|
+
count(restrictions.seconds_between_messages) > 0 ? `${count(restrictions.seconds_between_messages)}s posting interval` : "",
|
|
86
|
+
].filter(Boolean);
|
|
87
|
+
return (
|
|
88
|
+
<article className="enrichment-account">
|
|
89
|
+
<header><div><strong>{text(account.display_name, "Unknown participant")}</strong><small>{readable(text(account.relationship, "participant"))} · {text(account.reference, "Reference unavailable")}</small></div><span>{readable(text(account.status, "unknown"))}</span></header>
|
|
90
|
+
<dl>
|
|
91
|
+
<div><dt>Account age</dt><dd>{accountAge(createdAt)}</dd></div>
|
|
92
|
+
<div><dt>Messages</dt><dd>{count(totals.messages)}</dd></div>
|
|
93
|
+
<div><dt>Moderated</dt><dd>{count(totals.moderated_messages)}</dd></div>
|
|
94
|
+
<div><dt>Media</dt><dd>{count(totals.media)}</dd></div>
|
|
95
|
+
<div><dt>Held media</dt><dd>{count(totals.held_media)}</dd></div>
|
|
96
|
+
<div><dt>Shared rooms</dt><dd>{count(totals.joined_rooms)}</dd></div>
|
|
97
|
+
</dl>
|
|
98
|
+
{createdAt ? <p className="enrichment-account-tenure">Joined {dateTime(createdAt)}</p> : null}
|
|
99
|
+
<p>{readable(text(account.role, "member"))} · {readable(text(account.age_band, "age unknown"))} · {readable(text(account.minor_protection, "standard protection"))}</p>
|
|
100
|
+
{restrictionParts.length ? <small className="enrichment-restriction">{restrictionParts.join(" · ")}</small> : null}
|
|
101
|
+
{text(account.moderation_reason) ? <small className="enrichment-restriction">Moderation reason: {text(account.moderation_reason)}</small> : null}
|
|
102
|
+
</article>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function AccountHistoryResult({ data }: { data: DataRecord }) {
|
|
107
|
+
const accounts = records(data.accounts);
|
|
108
|
+
const events = records(data.moderation_events);
|
|
109
|
+
if (!accounts.length && !events.length) return null;
|
|
110
|
+
return (
|
|
111
|
+
<div className="enrichment-history">
|
|
112
|
+
{accounts.length ? <div className="enrichment-account-list">{accounts.map((account, index) => <AccountResult account={account} key={text(account.reference, `account-${index}`)} />)}</div> : null}
|
|
113
|
+
{events.length ? <details className="enrichment-expand"><summary>{events.length} moderation event{events.length === 1 ? "" : "s"}</summary><div className="enrichment-event-list">{events.map((event, index) => <article key={text(event.id, `event-${index}`)}><strong>{readable(text(event.action, "Moderation event"))}</strong><span>{readable(text(event.target_type, "target"))} · {text(event.target_reference, "Reference unavailable")}</span>{text(event.reason) ? <p>{text(event.reason)}</p> : null}<small>{text(event.created_at) ? dateTime(text(event.created_at)) : "Time unavailable"}</small></article>)}</div></details> : null}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Finding({ finding }: { finding: FindingRecord }) {
|
|
119
|
+
const data = record(finding.data);
|
|
120
|
+
const state = findingState(finding);
|
|
121
|
+
const hasConversation = records(data.messages).length > 0;
|
|
122
|
+
const hasHistory = records(data.accounts).length > 0 || records(data.moderation_events).length > 0;
|
|
123
|
+
return (
|
|
124
|
+
<article className="enrichment-finding">
|
|
125
|
+
<header><div><strong>{findingTitle(finding)}</strong><small>{finding.summary || "The enrichment completed without a summary."}</small></div><span className={`badge${state !== "available" ? " urgent" : ""}`}>{readable(state)}</span></header>
|
|
126
|
+
{hasConversation ? <ConversationResult data={data} /> : null}
|
|
127
|
+
{hasHistory ? <AccountHistoryResult data={data} /> : null}
|
|
128
|
+
<details className="enrichment-technical"><summary>Source and technical result</summary><dl><dt>Subject</dt><dd>{readable(finding.subjectType || "report")} · {finding.subjectReference || "Current report"}</dd><dt>Component</dt><dd>{finding.componentKey || finding.componentVersionId || "Unavailable"}{finding.componentVersion ? ` · version ${finding.componentVersion}` : ""}</dd><dt>Recorded</dt><dd>{finding.createdAt ? dateTime(finding.createdAt) : "Unavailable"}</dd></dl>{Object.keys(data).length ? <pre>{JSON.stringify(data, null, 2)}</pre> : <p>No structured output was retained.</p>}</details>
|
|
129
|
+
</article>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function EmptyEnrichment({ workflowRuns, permitted }: { workflowRuns: WorkflowRunRecord[]; permitted: boolean }) {
|
|
134
|
+
if (!permitted) return <div className="enrichment-empty"><strong>Enriched context is restricted</strong><p>Your role cannot read customer evidence or enrichment results.</p></div>;
|
|
135
|
+
const needsRepair = workflowRuns.some((run) => ["failed", "repair_required"].includes(run.state));
|
|
136
|
+
if (needsRepair) return <div className="enrichment-empty danger"><strong>Enrichment needs attention</strong><p>The workflow did not complete successfully. Open Technical workflow details to see its state.</p></div>;
|
|
137
|
+
if (workflowRuns.some((run) => run.state === "succeeded")) return <div className="enrichment-empty"><strong>No enrichment was produced</strong><p>The workflow completed without entering an enrichment step. This report did not contain the signed product identifiers required by that branch.</p></div>;
|
|
138
|
+
return <div className="enrichment-empty"><strong>No enrichment has run</strong><p>No completed workflow has attached customer context to this report yet.</p></div>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function EnrichmentFindings({ findings, workflowRuns, permitted }: { findings: FindingRecord[]; workflowRuns: WorkflowRunRecord[]; permitted: boolean }) {
|
|
142
|
+
return (
|
|
143
|
+
<section className="enrichment-section">
|
|
144
|
+
<div className="enrichment-heading"><div><h3>Enriched context</h3><p>Read-only customer data returned by the workflow’s enrichment tools and stored with this report.</p></div>{findings.length ? <span>{findings.length} result{findings.length === 1 ? "" : "s"}</span> : null}</div>
|
|
145
|
+
{findings.length ? <div className="enrichment-finding-list">{findings.map((finding, index) => <Finding finding={finding} key={finding.id ?? `${finding.findingType}-${index}`} />)}</div> : <EmptyEnrichment workflowRuns={workflowRuns} permitted={permitted} />}
|
|
146
|
+
</section>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState, useSyncExternalStore, type FormEvent } from "react";
|
|
2
2
|
import { AnalystAvatar } from "../components/AnalystIdentity";
|
|
3
3
|
import { dateTime, readable, relativeTime, safeCustomerUrl } from "../lib/format";
|
|
4
|
+
import { EnrichmentFindings } from "./EnrichmentFindings";
|
|
4
5
|
import {
|
|
5
6
|
dispatchClaimReport,
|
|
6
7
|
dispatchCloseReport,
|
|
@@ -8,8 +9,9 @@ import {
|
|
|
8
9
|
dispatchReleaseReport,
|
|
9
10
|
dispatchReporterMessage,
|
|
10
11
|
} from "./events";
|
|
12
|
+
import { otherReviewersOnReport, reportPresenceLabel } from "./presence-presentation";
|
|
11
13
|
import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
|
|
12
|
-
import type { ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
|
|
14
|
+
import type { PresenceAnalyst, ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
|
|
13
15
|
|
|
14
16
|
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
|
15
17
|
return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
|
|
@@ -19,6 +21,15 @@ function EmptyLine({ children }: { children: React.ReactNode }) {
|
|
|
19
21
|
return <p className="detail-empty">{children}</p>;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
function ReportCollaborators({ analysts }: { analysts: PresenceAnalyst[] }) {
|
|
25
|
+
if (!analysts.length) return null;
|
|
26
|
+
const label = reportPresenceLabel(analysts);
|
|
27
|
+
return <div className="report-collaborators" aria-label={label}>
|
|
28
|
+
<span className="presence-avatar-stack" aria-hidden="true">{analysts.slice(0, 3).map((analyst) => <AnalystAvatar analyst={analyst} size="small" key={analyst.actorId} />)}{analysts.length > 3 ? <span className="presence-overflow-avatar">+{analysts.length - 3}</span> : null}</span>
|
|
29
|
+
<span>{label}</span>
|
|
30
|
+
</div>;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
function answerOptionLabel(answer: ReportAnswer, value: string): string {
|
|
23
34
|
return answer.options?.find((option) => option.value === value)?.label || readable(value);
|
|
24
35
|
}
|
|
@@ -103,7 +114,7 @@ const decisionOptions = [
|
|
|
103
114
|
{ value: "duplicate", label: "Duplicate report" },
|
|
104
115
|
] as const;
|
|
105
116
|
|
|
106
|
-
function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, statusMessage, error }: {
|
|
117
|
+
function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, readEvidence, pendingAction, messageMutationVersion, statusMessage, error }: {
|
|
107
118
|
report: ReportDetailRecord;
|
|
108
119
|
actorId: string;
|
|
109
120
|
generatedAt: string;
|
|
@@ -111,6 +122,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
111
122
|
claimReports: boolean;
|
|
112
123
|
messageReporters: boolean;
|
|
113
124
|
decideReports: boolean;
|
|
125
|
+
readEvidence: boolean;
|
|
114
126
|
pendingAction: "claim" | "release" | "message" | "decision" | null;
|
|
115
127
|
messageMutationVersion: number;
|
|
116
128
|
statusMessage: string;
|
|
@@ -207,12 +219,12 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
207
219
|
</div></section>
|
|
208
220
|
</div>
|
|
209
221
|
|
|
222
|
+
<EnrichmentFindings findings={findings} workflowRuns={workflowRuns} permitted={readEvidence} />
|
|
223
|
+
|
|
210
224
|
{workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`} aria-hidden="true" /><div><strong>{timelineTitle(item)}</strong><span>{timelineDetail(item)}</span><small>{dateTime(item.createdAt)} · {relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div>{workflowRuns.length || technicalTimeline.length ? <details className="workflow-technical"><summary>Technical workflow details <span>{workflowRuns.length} workflow run{workflowRuns.length === 1 ? "" : "s"}</span></summary><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div>{technicalTimeline.length ? <p className="technical-event-count">{technicalTimeline.length} low-level execution event{technicalTimeline.length === 1 ? "" : "s"} recorded in the audit trail.</p> : null}</details> : null}</section> : null}
|
|
211
225
|
|
|
212
226
|
<section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
|
|
213
227
|
|
|
214
|
-
{findings.length ? <section><h3>Component findings</h3><div className="finding-result-list">{findings.map((finding, index) => <article key={finding.id ?? `${finding.findingType}-${index}`}><div><strong>{finding.findingType}</strong><small>{finding.summary}{finding.componentVersionId ? ` · component ${finding.componentVersionId}` : ""}{finding.expiresAt ? ` · expires ${dateTime(finding.expiresAt)}` : ""}</small>{finding.data ? <pre>{JSON.stringify(finding.data, null, 2)}</pre> : null}</div><span className={`badge${finding.freshness !== "available" ? " urgent" : ""}`}>{readable(finding.freshness || finding.status)}</span></article>)}</div></section> : null}
|
|
215
|
-
|
|
216
228
|
{decisions.length ? <section><h3>Decisions & rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}
|
|
217
229
|
|
|
218
230
|
{deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}
|
|
@@ -235,10 +247,11 @@ export function ReportDrawer() {
|
|
|
235
247
|
const snapshot = useSyncExternalStore(subscribeToReportWorkspace, getReportWorkspaceSnapshot, getReportWorkspaceSnapshot);
|
|
236
248
|
const detail = snapshot.detail;
|
|
237
249
|
const open = Boolean(detail.reportId);
|
|
250
|
+
const collaborators = detail.report ? otherReviewersOnReport(snapshot.presence, detail.report.id, snapshot.actorId) : [];
|
|
238
251
|
return (
|
|
239
252
|
<dialog className="detail-dialog report-drawer" open={open} aria-labelledby="report-detail-title" onCancel={(event) => { event.preventDefault(); dispatchCloseReport(); }} data-react-slice="report-drawer">
|
|
240
|
-
<div className="detail-head"><div><h2 id="report-detail-title">{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${readable(detail.report.queueId || "unassigned")}` : "Loading report…"}</p
|
|
241
|
-
<div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} statusMessage={detail.statusMessage} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
|
|
253
|
+
<div className="detail-head"><div><h2 id="report-detail-title">{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${readable(detail.report.queueId || "unassigned")}` : "Loading report…"}</p><ReportCollaborators analysts={collaborators} /></div><button className="icon-button" type="button" aria-label="Close report detail" onClick={dispatchCloseReport}>×</button></div>
|
|
254
|
+
<div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} readEvidence={snapshot.permissions.readEvidence} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} statusMessage={detail.statusMessage} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
|
|
242
255
|
</dialog>
|
|
243
256
|
);
|
|
244
257
|
}
|