create-safest-tools 0.6.0 → 0.7.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/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { dateTime, readable } from "../lib/format";
|
|
3
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
4
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
5
|
+
import { dispatchOpenReport } from "./events";
|
|
6
|
+
import type { ReportDetailRecord, WorkflowActionRecord } from "./types";
|
|
7
|
+
|
|
8
|
+
type ActionDetail = { proposal: { target: unknown; payload: unknown; preconditions: unknown; receipt: unknown }; attempts: { id: string; attemptNumber: number; responseStatus: number | null; errorCode: string | null; startedAt: string }[] };
|
|
9
|
+
function actionState(action: WorkflowActionRecord) {
|
|
10
|
+
if (action.confirmationState === "awaiting_confirmation") return "Awaiting customer confirmation";
|
|
11
|
+
if (action.confirmationState === "legacy_applied") return "Previously recorded as applied";
|
|
12
|
+
if (action.state === "applied") return "Customer confirmed completion";
|
|
13
|
+
if (action.errorCode === "action_confirmation_timed_out") return "Confirmation overdue";
|
|
14
|
+
if (action.state === "applying") return "Sending to customer";
|
|
15
|
+
if (action.state === "queued") return "Waiting to send";
|
|
16
|
+
if (action.state === "failed" && !action.terminal) return "Retry scheduled";
|
|
17
|
+
return readable(action.state);
|
|
18
|
+
}
|
|
19
|
+
function Action({ action, reportId, canRetry, canInspect, onReview }: { action: WorkflowActionRecord; reportId: string; canRetry: boolean; canInspect: boolean; onReview: () => void }) {
|
|
20
|
+
const [expanded, setExpanded] = useState(false);
|
|
21
|
+
const [detail, setDetail] = useState<ActionDetail | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [pending, setPending] = useState(false);
|
|
24
|
+
const [error, setError] = useState("");
|
|
25
|
+
const [notice, setNotice] = useState("");
|
|
26
|
+
const [epoch, setEpoch] = useState(0);
|
|
27
|
+
const activeRequest = useRef(false);
|
|
28
|
+
const retryKey = useRef<string | null>(null);
|
|
29
|
+
useEffect(() => { if (action.state === "applied") { setNotice(""); retryKey.current = null; } }, [action.state]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!expanded || !action.id) return;
|
|
32
|
+
let active = true; setLoading(true);
|
|
33
|
+
void requestJson<ActionDetail>(`/v1/admin/actions/${encodeURIComponent(action.id)}`).then(result => {
|
|
34
|
+
if (active) { setDetail(result); setError(""); }
|
|
35
|
+
}).catch(cause => { if (active) setError(errorMessage(cause, "The action details could not be loaded.")); })
|
|
36
|
+
.finally(() => { if (active) setLoading(false); });
|
|
37
|
+
return () => { active = false; };
|
|
38
|
+
}, [expanded, action.id, action.state, action.attemptCount, epoch]);
|
|
39
|
+
const retry = async () => {
|
|
40
|
+
if (!action.id || !action.payloadDigest || activeRequest.current) return;
|
|
41
|
+
activeRequest.current = true; setPending(true); setError(""); retryKey.current ??= crypto.randomUUID();
|
|
42
|
+
try {
|
|
43
|
+
await requestJson(`/v1/admin/actions/${encodeURIComponent(action.id)}/retry`, {
|
|
44
|
+
method: "POST", headers: { "content-type": "application/json", "idempotency-key": retryKey.current },
|
|
45
|
+
body: JSON.stringify({ payload_digest: action.payloadDigest }),
|
|
46
|
+
});
|
|
47
|
+
setNotice("The same approved action is queued again. The case remains open until the customer confirms completion.");
|
|
48
|
+
retryKey.current = null; dispatchOpenReport(reportId); setEpoch(value => value + 1);
|
|
49
|
+
} catch (cause) { setError(errorMessage(cause, "The connection was interrupted. Retry to check the same request.")); }
|
|
50
|
+
finally { activeRequest.current = false; setPending(false); }
|
|
51
|
+
};
|
|
52
|
+
const state = action.confirmationState || action.state;
|
|
53
|
+
return <article className="case-customer-action">
|
|
54
|
+
<div className="case-review-heading"><h4>{action.capabilityName || readable(action.actionCode)}</h4><span className={`badge${action.state === "failed" && action.terminal ? " urgent" : ""}`}>{actionState(action)}</span></div>
|
|
55
|
+
{state === "awaiting_confirmation" ? <p>The customer accepted this request {dateTime(action.acceptedAt || "")}. Safest is waiting for completion until {dateTime(action.confirmationDeadlineAt || "")}. The action has not been confirmed.</p>
|
|
56
|
+
: state === "legacy_applied" ? <p>This action was recorded by an earlier runtime. A precise customer completion receipt is not available.</p>
|
|
57
|
+
: action.state === "applied" ? <p>The customer confirmed this exact action {dateTime(action.confirmedAt || "")}.</p>
|
|
58
|
+
: action.state === "approval_required" ? <p>Review the proposed target and action in the playbook’s approval task before anything is sent.</p>
|
|
59
|
+
: action.errorCode === "action_confirmation_timed_out" ? <p>The customer did not confirm completion before the deadline. Check its action status before retrying; Safest cannot yet verify the outcome.</p>
|
|
60
|
+
: action.state === "failed" ? <p>{action.terminal ? "This action needs attention." : "Safest will retry the same action automatically."} {readable(action.errorCode || "Customer action failed")}. The outcome remains unconfirmed.</p>
|
|
61
|
+
: <p>The action remains unconfirmed until the customer returns a completion receipt.</p>}
|
|
62
|
+
<p className="form-help">{action.attemptCount || 0} delivery attempt{action.attemptCount === 1 ? "" : "s"}{(action.deliveryCycle || 1) > 1 ? ` · Retry ${action.deliveryCycle! - 1}` : ""}</p>
|
|
63
|
+
{action.state === "approval_required" ? <button type="button" className="secondary" onClick={onReview}>Open action approval</button> : null}
|
|
64
|
+
{canInspect ? <details onToggle={event => setExpanded(event.currentTarget.open)}><summary>Inspect action and delivery history</summary>
|
|
65
|
+
{loading ? <p role="status">Loading action details…</p> : null}
|
|
66
|
+
{detail ? <><dl className="case-action-details"><dt>Customer target</dt><dd><pre tabIndex={0}>{JSON.stringify(detail.proposal.target, null, 2)}</pre></dd><dt>Approved action details</dt><dd><pre tabIndex={0}>{JSON.stringify(detail.proposal.payload, null, 2)}</pre></dd></dl>
|
|
67
|
+
{detail.attempts.length ? <ol>{detail.attempts.map(attempt => <li key={attempt.id}>Attempt {attempt.attemptNumber} · {dateTime(attempt.startedAt)} · {attempt.errorCode ? readable(attempt.errorCode) : attempt.responseStatus ? `HTTP ${attempt.responseStatus}` : "No response recorded"}</li>)}</ol> : <p>No customer requests have been sent.</p>}
|
|
68
|
+
{detail.proposal.receipt ? <details><summary>Customer receipt</summary><pre tabIndex={0}>{JSON.stringify(detail.proposal.receipt, null, 2)}</pre></details> : null}
|
|
69
|
+
{action.state === "failed" && action.terminal ? action.retryable && canRetry ? <button type="button" className="primary" disabled={pending} onClick={() => void retry()}>{pending ? "Queuing retry…" : "Retry the same approved action"}</button>
|
|
70
|
+
: <p className="form-help">{!canRetry ? "An operator with action recovery permission can review a retry." : "The existing authorization cannot be retried. Review the changed or expired proposal before authorizing further action."}</p> : null}
|
|
71
|
+
</> : !loading && !error ? <p>Action details are unavailable.</p> : null}
|
|
72
|
+
</details> : <p className="form-help">Detailed customer action data is restricted for your role.</p>}
|
|
73
|
+
{notice ? <p role="status">{notice}</p> : null}{error ? <p role="alert" className="error">{error} <button type="button" className="text-button" onClick={() => setEpoch(value => value + 1)}>Reload action details</button></p> : null}
|
|
74
|
+
</article>;
|
|
75
|
+
}
|
|
76
|
+
export function CaseActions({ report, onReview }: { report: ReportDetailRecord; onReview: () => void }) {
|
|
77
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
78
|
+
return <section className="case-customer-actions" aria-label="Customer actions"><h3>Customer actions</h3><p>Approval permits an action. Customer confirmation establishes that it completed.</p>
|
|
79
|
+
{(report.workflowActions || []).filter(action => action.recordKind === "proposal").map(action => <Action key={action.id} action={action} reportId={report.id} canRetry={shell.session?.permissions.operateActions === true} canInspect={shell.session?.permissions.readSensitiveEvidence === true || shell.session?.permissions.approveActions === true} onReview={onReview} />)}
|
|
80
|
+
</section>;
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { RecordAppealDialog } from "../appeals/RecordAppealDialog";
|
|
3
|
+
import { dispatchOpenAppeal } from "../appeals/events";
|
|
4
|
+
import { dateTime, readable } from "../lib/format";
|
|
5
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
6
|
+
import { dispatchCloseReport, dispatchOpenReport } from "./events";
|
|
7
|
+
import { getReportWorkspaceSnapshot } from "./store";
|
|
8
|
+
import type { ReportDetailRecord } from "./types";
|
|
9
|
+
|
|
10
|
+
export function RelatedReports({report}:{report:ReportDetailRecord}) {
|
|
11
|
+
return <section className="case-guidance" aria-label="Related reports"><h3>Related reports</h3><p>Reports grouped by matching intake. Each keeps its own evidence, reviewer and outcome.</p>
|
|
12
|
+
{report.relatedReports?.length ? <ul className="case-related-list">{report.relatedReports.map(item=><li key={item.id}><div><strong>{item.reference}</strong><p>{readable(item.reasonCode)} · {readable(item.state)} · {item.queueName || "Unassigned"}</p><small>{dateTime(item.submittedAt)}</small></div><button className="secondary" type="button" onClick={()=>dispatchOpenReport(item.id)}>Open related case</button></li>)}</ul> : <p>No related reports are available in your case access.</p>}
|
|
13
|
+
</section>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CaseAppeals({report}:{report:ReportDetailRecord}) {
|
|
17
|
+
const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
|
|
18
|
+
const canReview=shell.session?.permissions.reviewAppeals===true;
|
|
19
|
+
const [recording,setRecording]=useState(false);
|
|
20
|
+
const eligible=["resolved_by_human","resolved_by_ai","appealed","closed"].includes(report.state) && Boolean(report.decisions?.length);
|
|
21
|
+
const open=(id:string)=>{ dispatchCloseReport(); if(!getReportWorkspaceSnapshot().detail.reportId) dispatchOpenAppeal(id); };
|
|
22
|
+
return <section className="case-guidance" aria-label="Independent appeals"><h3>Independent appeals</h3><p>The original decision and customer action remain in this case. A different reviewer assesses each participant’s challenge.</p>
|
|
23
|
+
{report.appeals?.length ? <ul className="case-related-list">{report.appeals.map(appeal=><li key={appeal.id}><div><strong>{appeal.audience==="affected_user"?"Affected person":"Reporter"} appeal</strong><p>{readable(appeal.state)} · {dateTime(appeal.submittedAt)}</p>{appeal.completionState ? <small>{appeal.completionState==="completed"?"Follow-up complete":appeal.completionState==="legacy"?"Historical verdict":appeal.correctionRequired&&!appeal.correctionConfirmedAt?"Customer correction needs verification":"Participant delivery outstanding"}</small>:<small>Waiting for an independent review</small>}</div>{canReview?<button className="secondary" type="button" onClick={()=>open(appeal.id)}>Open appeal</button>:null}</li>)}</ul>:<p>No appeal has been recorded.</p>}
|
|
24
|
+
{canReview&&eligible?<button className="secondary" type="button" onClick={()=>setRecording(true)}>Record participant appeal</button>:null}
|
|
25
|
+
{recording ? <RecordAppealDialog open reportReference={report.reference} onClose={()=>setRecording(false)} onRecorded={()=>dispatchOpenReport(report.id)} /> : null}
|
|
26
|
+
</section>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { dateTime, readable } from "../lib/format";
|
|
3
|
+
import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
|
|
4
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
5
|
+
import { dispatchOpenReport } from "./events";
|
|
6
|
+
import type { DeliveryNotice, ReportDetailRecord } from "./types";
|
|
7
|
+
|
|
8
|
+
function Notice({ notice, report, canRetry }: { notice: DeliveryNotice; report: ReportDetailRecord; canRetry: boolean }) {
|
|
9
|
+
const [pending, setPending] = useState(false), [error, setError] = useState("");
|
|
10
|
+
const request = useRef<{ key: string; updatedAt: string } | null>(null);
|
|
11
|
+
const busy = useRef(false);
|
|
12
|
+
const body = report.messages?.find(message => message.id === notice.messageId)?.body;
|
|
13
|
+
const failed = ["failed", "suppressed"].includes(notice.deliveryState);
|
|
14
|
+
const retry = async () => {
|
|
15
|
+
if (busy.current || !notice.id) return;
|
|
16
|
+
busy.current = true; setPending(true); setError("");
|
|
17
|
+
request.current ??= {key: crypto.randomUUID(), updatedAt: notice.updatedAt};
|
|
18
|
+
try {
|
|
19
|
+
await requestJson(`/v1/admin/reports/${encodeURIComponent(report.id)}/notices/${encodeURIComponent(notice.id)}/retry`, {
|
|
20
|
+
method: "POST", headers: {"content-type": "application/json", "idempotency-key": request.current.key},
|
|
21
|
+
body: JSON.stringify({expected_updated_at: request.current.updatedAt}),
|
|
22
|
+
});
|
|
23
|
+
request.current = null; dispatchOpenReport(report.id);
|
|
24
|
+
} catch (cause) { if (cause instanceof ApiRequestError && cause.status < 500) {request.current = null; dispatchOpenReport(report.id);} setError(errorMessage(cause, "Delivery recovery was interrupted. Retry to check the same request.")); }
|
|
25
|
+
finally {busy.current = false; setPending(false);}
|
|
26
|
+
};
|
|
27
|
+
useEffect(() => { if (!failed) {request.current = null; setError("");} }, [failed]);
|
|
28
|
+
const audience = notice.audience === "affected_user" ? "Affected person" : "Reporter";
|
|
29
|
+
return <article className="case-notice"><div className="case-notice-heading"><h4>{audience} {notice.purpose === "appeal" ? "appeal outcome" : notice.decisionId ? "outcome" : "update"}</h4><span className={`badge${failed ? " urgent" : ""}`}>{notice.deliveryState === "delivered" ? "Handed to channel" : notice.deliveryState === "suppressed" ? "No eligible delivery" : notice.deliveryState === "failed" ? "Delivery needs attention" : "Waiting for handoff"}</span></div>
|
|
30
|
+
<p>{notice.channel === "email" ? "Email" : notice.channel === "customer_webhook" ? "Customer notification channel" : "No channel selected"} · {notice.attemptCount ?? 0} attempt{notice.attemptCount === 1 ? "" : "s"} · {dateTime(notice.updatedAt)}</p>
|
|
31
|
+
{notice.errorCode ? <p>{readable(notice.errorCode)}</p> : null}
|
|
32
|
+
<details><summary>Inspect {audience.toLowerCase()} notice</summary>{body ? <blockquote className="playbook-notice-preview">{body}</blockquote> : <p>The original message is unavailable in this view.</p>}{failed ? <><p>Retry preserves this audience and message. Automatic attempts remain in the delivery history.</p>{canRetry && body ? <button type="button" className="secondary" disabled={pending} onClick={() => void retry()}>{pending ? "Queuing delivery…" : "Retry original notice"}</button> : <p>An operator with participant messaging permission can review delivery recovery.</p>}</> : null}</details>
|
|
33
|
+
{error ? <p role="alert" className="error">{error}</p> : null}
|
|
34
|
+
</article>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CaseNotices({ report }: { report: ReportDetailRecord }) {
|
|
38
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
39
|
+
const waiting = report.notices?.some(notice => notice.deliveryState === "queued");
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!waiting) return;
|
|
42
|
+
const timer = setInterval(() => {if (document.visibilityState !== "hidden") dispatchOpenReport(report.id);}, 5000);
|
|
43
|
+
return () => clearInterval(timer);
|
|
44
|
+
}, [report.id, waiting]);
|
|
45
|
+
return <section aria-label="Participant deliveries"><h3>Participant deliveries</h3><p>A handoff confirms that the configured channel accepted the notice. It does not confirm that the participant read it.</p>
|
|
46
|
+
{report.notices?.map(notice => <Notice key={notice.id} notice={notice} report={report} canRetry={shell.session?.permissions.messageReporters === true} />)}
|
|
47
|
+
</section>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEditingAccess } from "./use-editing-access";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { AnalystAvatar } from "../components/AnalystIdentity";
|
|
5
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
6
|
+
import { changeCaseOwner } from "./controller";
|
|
7
|
+
import { dispatchClaimReport, dispatchOpenReport } from "./events";
|
|
8
|
+
import type { ReportWorkspaceSnapshot } from "./types";
|
|
9
|
+
|
|
10
|
+
export function CaseOwnership({ snapshot }: { snapshot: ReportWorkspaceSnapshot }) {
|
|
11
|
+
const report = snapshot.detail.report!;
|
|
12
|
+
const assignment = report.assignment;
|
|
13
|
+
const mine = assignment?.reviewerId === snapshot.actorId;
|
|
14
|
+
const activeAccess = useEditingAccess(report.claim?.expiresAt);
|
|
15
|
+
const editable = mine && activeAccess;
|
|
16
|
+
const open = ["human_review", "human_review_requested", "awaiting_reporter"].includes(report.state);
|
|
17
|
+
const busy = Boolean(snapshot.detail.pendingAction);
|
|
18
|
+
const [handoff, setHandoff] = useState<{ transfer: boolean; version: number } | null>(null);
|
|
19
|
+
const [note, setNote] = useState("");
|
|
20
|
+
const [recipient, setRecipient] = useState("");
|
|
21
|
+
const [reviewers, setReviewers] = useState<Array<{ id: string; displayName: string }>>([]);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [error, setError] = useState("");
|
|
24
|
+
const [retry, setRetry] = useState(0);
|
|
25
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!handoff) return;
|
|
28
|
+
const previous = document.activeElement;
|
|
29
|
+
dialog.current?.showModal();
|
|
30
|
+
return () => { dialog.current?.close(); if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); };
|
|
31
|
+
}, [Boolean(handoff)]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!handoff?.transfer) return;
|
|
34
|
+
let active = true; setLoading(true); setError("");
|
|
35
|
+
requestJson<{ reviewers: typeof reviewers }>(`/v1/admin/reports/${encodeURIComponent(report.id)}/ownership`)
|
|
36
|
+
.then(result => { if (active) setReviewers(result.reviewers.filter(person => person.id !== assignment?.reviewerId)); })
|
|
37
|
+
.catch(cause => { if (active) setError(errorMessage(cause,"Reviewers could not be loaded.")); })
|
|
38
|
+
.finally(() => { if (active) setLoading(false); });
|
|
39
|
+
return () => { active = false; };
|
|
40
|
+
}, [handoff?.transfer, report.id, retry]);
|
|
41
|
+
const ownerName = mine ? "You" : assignment?.analyst?.displayName || "Another reviewer";
|
|
42
|
+
return <>
|
|
43
|
+
<section className="case-ownership" aria-label="Case ownership">
|
|
44
|
+
<div className="case-owner-copy">
|
|
45
|
+
{assignment ? <AnalystAvatar analyst={assignment.analyst || { displayName:ownerName }} size="small" /> : <span className="case-owner-empty" aria-hidden="true">○</span>}
|
|
46
|
+
<div><strong>{assignment ? `Case owner: ${ownerName}` : open ? "Unassigned case" : "Review complete"}</strong>
|
|
47
|
+
<p>{!open ? "Open Outcome to see the result." : !assignment ? "Start review to take responsibility." : mine ? editable ? "Editing access renews while you review." : "Still yours. Resume editing to continue." : "You can read this case while its owner reviews it."}</p>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div className="case-owner-actions">
|
|
51
|
+
{open && snapshot.permissions.claimReports && (!assignment || mine) ? <button type="button" className="primary" disabled={busy} onClick={() => editable ? dispatchOpenReport(report.id,true) : dispatchClaimReport()}>{snapshot.detail.pendingAction === "claim" ? "Starting…" : !assignment ? "Start review" : editable ? "Continue review" : "Resume editing"}</button> : null}
|
|
52
|
+
{open && assignment && snapshot.permissions.reassignReports && (mine || snapshot.permissions.manageConfiguration) ? <>
|
|
53
|
+
<button type="button" className="secondary" disabled={busy} onClick={() => { setError(""); setHandoff({ transfer:true,version:assignment.version }); }}>Transfer…</button>
|
|
54
|
+
<button type="button" className="text-button" disabled={busy} onClick={() => { setError(""); setHandoff({ transfer:false,version:assignment.version }); }}>Return to queue</button>
|
|
55
|
+
</> : null}
|
|
56
|
+
</div>
|
|
57
|
+
{snapshot.detail.error ? <p className="error" role="alert">{snapshot.detail.error}</p> : null}
|
|
58
|
+
{snapshot.detail.statusMessage ? <p className="case-owner-status" role="status">{snapshot.detail.statusMessage}</p> : null}
|
|
59
|
+
</section>
|
|
60
|
+
{handoff ? createPortal(<dialog ref={dialog} className="case-handoff-dialog" aria-labelledby="case-handoff-title" onCancel={event => { event.preventDefault(); if (!busy) setHandoff(null); }} onKeyDown={event => { if (event.key === "Escape") event.stopPropagation(); }}>
|
|
61
|
+
<form onSubmit={async event => {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
const changed = await changeCaseOwner({ reviewerId:handoff.transfer ? recipient : null, displayName:reviewers.find(person => person.id === recipient)?.displayName, version:handoff.version, note });
|
|
64
|
+
if (changed) { setHandoff(null); setNote(""); setRecipient(""); }
|
|
65
|
+
}}>
|
|
66
|
+
<h2 id="case-handoff-title">{handoff.transfer ? "Transfer case" : "Return case to queue"}</h2>
|
|
67
|
+
<p>{handoff.transfer ? "Choose who should take responsibility and tell them what to do next." : "Make this case available to another reviewer and explain what still needs attention."}</p>
|
|
68
|
+
{handoff.transfer ? <label>Transfer to<select required autoFocus value={recipient} disabled={busy || loading} onChange={event => setRecipient(event.currentTarget.value)}><option value="">{loading ? "Loading reviewers…" : "Choose a reviewer"}</option>{reviewers.map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label> : null}
|
|
69
|
+
{handoff.transfer && !loading && !error && !reviewers.length ? <p>No other eligible reviewers are available for this queue.</p> : null}
|
|
70
|
+
<label>Handoff note<textarea autoFocus={!handoff.transfer} required maxLength={4000} rows={5} value={note} disabled={busy} onChange={event => setNote(event.currentTarget.value)} placeholder="What have you checked? What should the next reviewer do?" /></label>
|
|
71
|
+
<p className="handoff-privacy">Only this note is shared with the team. Your unsent messages and decisions stay in your private draft. Unfinished playbook tasks you hold become available for a qualified reviewer.</p>
|
|
72
|
+
{error ? <p className="error" role="alert">{error} <button type="button" className="text-button" onClick={() => setRetry(value => value + 1)}>Retry</button></p> : null}
|
|
73
|
+
{snapshot.detail.error ? <p className="error" role="alert">{snapshot.detail.error}</p> : null}
|
|
74
|
+
<div className="handoff-actions"><button className="secondary" type="button" disabled={busy} onClick={() => setHandoff(null)}>Cancel</button><button className="primary" type="submit" disabled={busy || !note.trim() || (handoff.transfer && (!recipient || loading || Boolean(error)))}>{busy ? "Saving…" : handoff.transfer ? "Transfer case" : "Return to queue"}</button></div>
|
|
75
|
+
</form>
|
|
76
|
+
</dialog>, document.body) : null}
|
|
77
|
+
</>;
|
|
78
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEditingAccess } from "./use-editing-access";
|
|
2
|
+
import { readPrivateDraft, savePrivateDraft, removePrivateDraft } from "./private-drafts";
|
|
3
|
+
import { useEffect, useId, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
|
|
4
|
+
import { dateTime, readable } from "../lib/format";
|
|
5
|
+
import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
6
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
7
|
+
import { dispatchOpenReport, reportEvents } from "./events";
|
|
8
|
+
import type { ReportDetailRecord, ReportDestination } from "./types";
|
|
9
|
+
|
|
10
|
+
type Task = {
|
|
11
|
+
id: string; runId: string; state: string; taskType: string; assignedOperatorId: string | null;
|
|
12
|
+
claimExpiresAt: string | null; dueAt: string | null; actionProposalId: string | null;
|
|
13
|
+
context: Record<string, unknown>;
|
|
14
|
+
responseSchema: { properties?: { decision_code?: { enum?: string[] } } };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function ReviewTask({ task, report, actorId, canApprove, canDecide, onChanged }: {
|
|
18
|
+
task: Task; report: ReportDetailRecord; actorId: string; canApprove: boolean; canDecide: boolean; onChanged: (message: string) => void;
|
|
19
|
+
}) {
|
|
20
|
+
const fieldId = useId();
|
|
21
|
+
const approval = Boolean(task.actionProposalId);
|
|
22
|
+
const options = task.responseSchema?.properties?.decision_code?.enum ?? [];
|
|
23
|
+
const [savedDraft] = useState(() => readPrivateDraft(actorId, `task:${task.id}`));
|
|
24
|
+
const [decision, setDecision] = useState(savedDraft?.taskDecision || "");
|
|
25
|
+
const [rationale, setRationale] = useState(savedDraft?.rationale || "");
|
|
26
|
+
const [evidence, setEvidence] = useState<string[]>(savedDraft?.evidence || []);
|
|
27
|
+
const [findings, setFindings] = useState<string[]>(savedDraft?.findings || []);
|
|
28
|
+
const [pending, setPending] = useState(false);
|
|
29
|
+
const [error, setError] = useState("");
|
|
30
|
+
const activeRequest = useRef(false);
|
|
31
|
+
const savedRequest = useRef<{ fingerprint: string; key: string } | null>(null);
|
|
32
|
+
const dirty = Boolean(decision || rationale || evidence.length || findings.length);
|
|
33
|
+
const activeAccess = useEditingAccess(task.claimExpiresAt);
|
|
34
|
+
const mine = task.state === "claimed" && task.assignedOperatorId === actorId && activeAccess;
|
|
35
|
+
const claimable = task.state === "open" || (task.state === "claimed" && !activeAccess);
|
|
36
|
+
const canSubmit = approval ? canApprove : canDecide;
|
|
37
|
+
const title = approval ? "Approve the customer action" : typeof task.context.title === "string" ? task.context.title : "Review the case";
|
|
38
|
+
const instructions = typeof task.context.instructions === "string" ? task.context.instructions : report.reviewerInstructions;
|
|
39
|
+
const toggle = (items: string[], value: string, checked: boolean) => checked ? [...new Set([...items, value])] : items.filter(item => item !== value);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
savePrivateDraft(actorId,`task:${task.id}`, { message:"",awaitReporter:false,decisionCode:"",policyCode:"",rationale,reporterNotice:"",affectedNotice:"",actionCode:"",evidence,taskDecision:decision,findings });
|
|
43
|
+
}, [actorId,task.id,rationale,evidence,decision,findings]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const leaving = (event: Event) => { if (activeRequest.current) event.preventDefault(); };
|
|
46
|
+
window.addEventListener(reportEvents.beforeClose, leaving);
|
|
47
|
+
return () => window.removeEventListener(reportEvents.beforeClose, leaving);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const claim = async (release = false) => {
|
|
51
|
+
if (activeRequest.current) return;
|
|
52
|
+
activeRequest.current = true; setPending(true); setError("");
|
|
53
|
+
try {
|
|
54
|
+
await requestJson(`/v1/admin/tasks/${encodeURIComponent(task.id)}/claim`, { method: release ? "DELETE" : "POST", headers: mutationHeaders(false) });
|
|
55
|
+
onChanged(release ? "Task editing access released. Your private draft is saved in this tab." : "Task editing access is active. Choose an outcome and explain your reasoning.");
|
|
56
|
+
} catch (cause) { setError(errorMessage(cause, "The review claim could not be changed.")); }
|
|
57
|
+
finally { activeRequest.current = false; setPending(false); }
|
|
58
|
+
};
|
|
59
|
+
const submit = async (event: FormEvent) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
if (!mine || activeRequest.current || !decision || !rationale.trim()) return;
|
|
62
|
+
const response = approval ? { decision, reason: rationale.trim() } : { decision_code: decision, rationale: rationale.trim(), evidence_references: evidence, finding_ids: findings };
|
|
63
|
+
const fingerprint = JSON.stringify({ taskId: task.id, response });
|
|
64
|
+
if (savedRequest.current?.fingerprint !== fingerprint) savedRequest.current = { fingerprint, key: crypto.randomUUID() };
|
|
65
|
+
activeRequest.current = true; setPending(true); setError("");
|
|
66
|
+
try {
|
|
67
|
+
await requestJson(`/v1/admin/tasks/${encodeURIComponent(task.id)}/respond`, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": savedRequest.current.key }, body: JSON.stringify({ response }) });
|
|
68
|
+
removePrivateDraft(actorId,`task:${task.id}`); setDecision(""); setRationale(""); setEvidence([]); setFindings([]); savedRequest.current = null;
|
|
69
|
+
onChanged(approval ? "Approval recorded. The playbook will verify the customer action before completing the outcome." : "Review submitted. The playbook is continuing from your saved response.");
|
|
70
|
+
} catch (cause) { setError(cause instanceof ApiRequestError ? cause.message : "The connection was interrupted. Your response is still here. Retry to check the same submission."); }
|
|
71
|
+
finally { activeRequest.current = false; setPending(false); }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return <article className="case-review-task" data-task-id={task.id} tabIndex={-1} aria-label={title}>
|
|
75
|
+
<div className="case-review-heading"><div><h4>{title}</h4>{task.dueAt ? <small>Due {dateTime(task.dueAt)}</small> : null}</div><span className="badge">{mine ? "Your review" : readable(task.state)}</span></div>
|
|
76
|
+
{instructions ? <p className="case-review-instructions">{instructions}</p> : null}
|
|
77
|
+
{approval ? <div className="case-action-preview"><strong>{readable(String(task.context.action_code || "Customer action"))}</strong><p>Review the exact target and payload before approving.</p><dl><dt>Target</dt><dd><pre tabIndex={0} aria-label="Proposed action target">{JSON.stringify(task.context.target, null, 2)}</pre></dd><dt>Action details</dt><dd><pre tabIndex={0} aria-label="Proposed action details">{JSON.stringify(task.context.payload, null, 2)}</pre></dd></dl></div> : null}
|
|
78
|
+
{mine ? <p className="form-help">Editing access lasts until {dateTime(task.claimExpiresAt || "")}. Your case ownership stays unchanged. <button type="button" className="text-button" disabled={pending} onClick={() => void claim(true)}>Release task access</button></p> : claimable ? <button className="primary" type="button" disabled={pending || !canSubmit} onClick={() => void claim()}>{pending ? "Starting…" : "Start task"}</button> : <p className="form-help">Another reviewer holds this task. You can continue reading the case.</p>}
|
|
79
|
+
{approval || options.length ? <form onSubmit={event => void submit(event)}><fieldset disabled={!mine || pending || !canSubmit}><legend>{approval ? "Your approval" : "Your review"}</legend>
|
|
80
|
+
<div className="case-review-field"><label htmlFor={`${fieldId}-outcome`}>{approval ? "Action approval" : "Review outcome"}</label><select id={`${fieldId}-outcome`} required value={decision} onChange={event => setDecision(event.currentTarget.value)}><option value="">Choose an outcome</option>{(approval ? ["approved", "rejected"] : options).map(code => <option value={code} key={code}>{readable(code)}</option>)}</select></div>
|
|
81
|
+
<div className="case-review-field"><label htmlFor={`${fieldId}-rationale`}>{approval ? "Approval reason" : "Review rationale"}</label><textarea id={`${fieldId}-rationale`} required maxLength={approval ? 1000 : 2000} value={rationale} onChange={event => setRationale(event.currentTarget.value)} placeholder="Explain what the evidence establishes and how the policy applies." /></div>
|
|
82
|
+
{!approval ? <div className="case-review-sources"><strong>Sources supporting this decision</strong><p className="form-help">Select only the evidence you considered. Missing information should remain explicit in your rationale.</p>{(report.evidence ?? []).map(item => <label className="checkbox" key={item.reference}><input type="checkbox" checked={evidence.includes(item.reference)} onChange={event => setEvidence(toggle(evidence, item.reference, event.currentTarget.checked))} /><span>{item.reference} · {readable(item.availability)}</span></label>)}{(report.findings ?? []).filter(item => item.id && item.runId === task.runId && item.status === "available").map(item => <label className="checkbox" key={item.id}><input type="checkbox" checked={findings.includes(item.id!)} onChange={event => setFindings(toggle(findings, item.id!, event.currentTarget.checked))} /><span>{item.summary} · {item.componentKey || "Case finding"}</span></label>)}{!report.evidence?.length && !report.findings?.length ? <p>No evidence sources have been recorded yet.</p> : null}</div> : null}
|
|
83
|
+
<button className="primary" type="submit">{pending ? "Submitting…" : approval ? "Submit action approval" : "Submit review and continue"}</button>
|
|
84
|
+
</fieldset></form> : <p className="error">This task uses a response form that is not supported in the case workspace yet. Ask an administrator to inspect the published task.</p>}
|
|
85
|
+
{!canSubmit ? <p className="form-help">{approval ? "Your role can read this task but cannot approve customer actions." : "Your role can read this task but cannot submit a case decision."}</p> : null}
|
|
86
|
+
{dirty ? <p className="private-draft-status">Private task draft saved in this tab.</p> : null}
|
|
87
|
+
{error ? <p className="error" role="alert">{error}</p> : null}
|
|
88
|
+
</article>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function CaseReviewTasks({ report, onOutcome, destination }: { report: ReportDetailRecord; onOutcome: () => void; destination?: ReportDestination }) {
|
|
92
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
93
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
94
|
+
const [loading, setLoading] = useState(true);
|
|
95
|
+
const [error, setError] = useState("");
|
|
96
|
+
const [notice, setNotice] = useState("");
|
|
97
|
+
const [epoch, setEpoch] = useState(0);
|
|
98
|
+
const sectionRef = useRef<HTMLElement>(null);
|
|
99
|
+
const focusedRequest = useRef(0);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (loading || destination?.section !== "review" || !destination.taskId || focusedRequest.current === destination.request) return;
|
|
102
|
+
const frame = requestAnimationFrame(() => {
|
|
103
|
+
const target = [...(sectionRef.current?.querySelectorAll<HTMLElement>("[data-task-id]") || [])]
|
|
104
|
+
.find(element => element.dataset.taskId === destination.taskId);
|
|
105
|
+
if (!target || !target.getClientRects().length) return;
|
|
106
|
+
focusedRequest.current = destination.request;
|
|
107
|
+
target.focus({ preventScroll: true });
|
|
108
|
+
const content = target.closest<HTMLElement>(".report-detail-content");
|
|
109
|
+
if (content) {
|
|
110
|
+
const tabs = content.querySelector(".case-section-tabs")?.getBoundingClientRect().height || 0;
|
|
111
|
+
content.scrollTop += target.getBoundingClientRect().top - content.getBoundingClientRect().top - tabs - 12;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return () => cancelAnimationFrame(frame);
|
|
115
|
+
}, [destination, loading, tasks]);
|
|
116
|
+
const canReview = shell.session?.permissions.reviewTasks === true;
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!canReview) { setLoading(false); return; }
|
|
119
|
+
let active = true; let inFlight = false;
|
|
120
|
+
const refresh = async () => {
|
|
121
|
+
if (inFlight || document.visibilityState === "hidden") return;
|
|
122
|
+
inFlight = true;
|
|
123
|
+
try {
|
|
124
|
+
const result = await requestJson<{ tasks: Task[] }>(`/v1/admin/tasks?report_id=${encodeURIComponent(report.id)}&limit=100`);
|
|
125
|
+
if (active) { setTasks(result.tasks); setError(""); if (!result.tasks.some(task => ["open", "claimed"].includes(task.state))) dispatchOpenReport(report.id); }
|
|
126
|
+
} catch (cause) { if (active) setError(errorMessage(cause, "Review tasks could not be loaded.")); }
|
|
127
|
+
finally { inFlight = false; if (active) setLoading(false); }
|
|
128
|
+
};
|
|
129
|
+
void refresh(); const timer = setInterval(() => void refresh(), 5000);
|
|
130
|
+
return () => { active = false; clearInterval(timer); };
|
|
131
|
+
}, [report.id, canReview, epoch]);
|
|
132
|
+
const changed = (message: string) => { setNotice(message); setEpoch(value => value + 1); dispatchOpenReport(report.id); };
|
|
133
|
+
const open = tasks.filter(task => ["open", "claimed"].includes(task.state));
|
|
134
|
+
const actions = (report.workflowActions || []).filter(action => action.recordKind === "proposal");
|
|
135
|
+
const unresolved = actions.some(action => action.state !== "applied");
|
|
136
|
+
const failed = actions.some(action => action.state === "failed" && action.terminal);
|
|
137
|
+
const waitingNotices = (report.notices || []).some(notice => notice.decisionId && notice.deliveryState !== "delivered");
|
|
138
|
+
const terminal = report.workflowRuns?.some(run => run.runKind === "primary" && ["succeeded","failed","terminated","repair_required"].includes(run.state));
|
|
139
|
+
const completed = ["resolved_by_human","resolved_by_ai","closed","appealed"].includes(report.state);
|
|
140
|
+
return <section ref={sectionRef} className="case-next-step" aria-label="Playbook next step"><div className="case-review-heading"><div><h3>Next step</h3><p>This case follows its published playbook. Complete the current task to continue.</p></div><button className="secondary" type="button" onClick={() => { setEpoch(value => value + 1); dispatchOpenReport(report.id); }}>Refresh progress</button></div>
|
|
141
|
+
{!canReview ? <p>Your role can read this case. A reviewer with access to its queue must complete the next task.</p> : loading ? <p role="status">Loading the current review…</p> : open.length ? open.map(task => <ReviewTask key={task.id} task={task} report={report} actorId={shell.session!.actor.id} canApprove={shell.session?.permissions.approveActions === true} canDecide={shell.session?.permissions.decideReports === true} onChanged={changed} />) : completed ? <p>The review is recorded. <button type="button" className="text-button" onClick={onOutcome}>Inspect the outcome</button></p>
|
|
142
|
+
: unresolved ? <p>{failed ? "A customer action needs recovery. The case remains open." : "The playbook is waiting for customer action confirmation."} <button type="button" className="text-button" onClick={onOutcome}>Review customer actions</button></p>
|
|
143
|
+
: waitingNotices ? <p>Participant notice delivery is still outstanding. <button type="button" className="text-button" onClick={onOutcome}>Review participant notices</button></p>
|
|
144
|
+
: terminal ? <p>The playbook stopped without a completed outcome. Review its outcome and delivery history before continuing. <button type="button" className="text-button" onClick={onOutcome}>Inspect unresolved work</button></p>
|
|
145
|
+
: <p>The playbook is continuing. Its next review or approval will appear here.</p>}
|
|
146
|
+
{notice ? <p role="status">{notice}</p> : null}{error ? <p className="error" role="alert">{error} Use Refresh progress to try again.</p> : null}
|
|
147
|
+
</section>;
|
|
148
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readable } from "../lib/format";
|
|
2
|
+
import type { FindingRecord } from "./types";
|
|
3
|
+
const strings=(value:unknown):string[]=>Array.isArray(value)?value.filter((item):item is string=>typeof item==="string"):[];
|
|
4
|
+
const object=(value:unknown):Record<string,unknown>=>value && typeof value==="object" && !Array.isArray(value)?value as Record<string,unknown>:{};
|
|
5
|
+
function gapLabel(value:string) {
|
|
6
|
+
if(value.includes("budget"))return "The configured investigation budget was reached.";
|
|
7
|
+
if(value.includes("step_limit") || value.includes("proposal_missing"))return "The investigation reached its limit without a complete proposal.";
|
|
8
|
+
if(value.includes("time_limit"))return "The investigation timed out before it could complete.";
|
|
9
|
+
if(value.includes("tool_denied"))return "A requested tool was outside this playbook’s permitted scope.";
|
|
10
|
+
if(value.startsWith("investigation_") || value.startsWith("agent_"))return "The investigation could not validate a complete proposal. Review the available evidence directly.";
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export function InvestigationBrief({findings,onEvidence}:{findings:FindingRecord[];onEvidence?:()=>void}) {
|
|
14
|
+
const investigation=[...findings].reverse().find(finding=>finding.findingType==="case-investigation");
|
|
15
|
+
if(!investigation)return null;
|
|
16
|
+
const data=object(investigation.data),execution=object(data.investigation);
|
|
17
|
+
const gaps=strings(data.missing_information),uncertainty=strings(data.uncertainty),citations=strings(data.citations);
|
|
18
|
+
const sources=Array.isArray(execution.sources)?execution.sources.map(object):[];
|
|
19
|
+
return <section className="investigation-brief" aria-label="Investigation brief">
|
|
20
|
+
<div className="investigation-brief-heading"><h3>Investigation brief</h3><span className="badge">{data.result_code==="needs_information"?"Needs more information":"Ready for review"}</span></div>
|
|
21
|
+
<p>{typeof data.summary==="string"?data.summary:investigation.summary}</p>
|
|
22
|
+
<p className="form-help">This is a proposal. Check the cited sources and apply your policy before deciding.</p>
|
|
23
|
+
{execution.fixture===true?<p className="investigation-fixture">Local test model — this result validates workflow behavior only.</p>:null}
|
|
24
|
+
{gaps.length?<div><h4>Missing information</h4><ul>{gaps.map((gap,index)=><li key={index}>{gapLabel(gap)}</li>)}</ul></div>:null}
|
|
25
|
+
{uncertainty.length?<div><h4>Uncertainty</h4><ul>{uncertainty.map((item,index)=><li key={index}>{gapLabel(item)}</li>)}</ul></div>:null}
|
|
26
|
+
<div><h4>Sources considered</h4>{citations.length?<ul>{citations.map(id=>{const finding=findings.find(f=>f.id===id);const source=sources.find(s=>s.id===id);return <li key={id}>{finding?.summary || (typeof source?.label==="string"?readable(source.label):"Case intake")}<small>{id}</small></li>;})}</ul>:<p>No source was validated for this proposal.</p>}{onEvidence && citations.length?<button className="secondary" type="button" onClick={onEvidence}>Review cited evidence</button>:null}</div>
|
|
27
|
+
<details><summary>Investigation activity</summary><p>{Number(execution.steps)||0} model steps · {Number(execution.tool_calls)||0} tool calls</p>{Array.isArray(execution.trace)?<ol>{execution.trace.map(object).map((entry,index)=><li key={index}>{readable(String(entry.tool))} · {readable(String(entry.status))}</li>)}</ol>:null}<p>{typeof execution.provider==="string"?readable(execution.provider):"Configured provider"} · {String(execution.model||"Configured model")}</p></details>
|
|
28
|
+
</section>;
|
|
29
|
+
}
|