create-safest-tools 0.6.1 → 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.
Files changed (154) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/template/README.md +1 -1
  5. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  6. package/template/console/analytics/controller.ts +10 -2
  7. package/template/console/analytics/types.ts +7 -0
  8. package/template/console/appeals/AppealFollowup.tsx +171 -0
  9. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  10. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  11. package/template/console/appeals/appeal-corrections.css +29 -0
  12. package/template/console/appeals/controller.ts +40 -14
  13. package/template/console/appeals/events.ts +1 -0
  14. package/template/console/appeals/types.ts +6 -0
  15. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  16. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  17. package/template/console/command/CommandCentre.tsx +12 -6
  18. package/template/console/command/controller.ts +13 -9
  19. package/template/console/command/presentation.ts +1 -6
  20. package/template/console/command/store.ts +1 -0
  21. package/template/console/command/types.ts +1 -0
  22. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  23. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  24. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  25. package/template/console/configuration/types.ts +1 -1
  26. package/template/console/lib/priority.ts +13 -0
  27. package/template/console/operations/CaseEscalations.tsx +142 -0
  28. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  29. package/template/console/operations/case-escalations.css +20 -0
  30. package/template/console/people/PeopleWorkspace.tsx +8 -4
  31. package/template/console/people/PersonAccessDialog.tsx +40 -0
  32. package/template/console/people/types.ts +1 -0
  33. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  34. package/template/console/registry/IntegrationSetup.tsx +41 -0
  35. package/template/console/registry/IntegrationVerification.tsx +54 -0
  36. package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
  37. package/template/console/registry/RegistryDialog.tsx +34 -4
  38. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  39. package/template/console/registry/integration-verification.css +25 -0
  40. package/template/console/reports/CaseActions.tsx +81 -0
  41. package/template/console/reports/CaseContinuity.tsx +27 -0
  42. package/template/console/reports/CaseNotices.tsx +48 -0
  43. package/template/console/reports/CaseOwnership.tsx +78 -0
  44. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  45. package/template/console/reports/InvestigationBrief.tsx +29 -0
  46. package/template/console/reports/ReportDrawer.tsx +109 -67
  47. package/template/console/reports/ReportWorkspace.tsx +11 -12
  48. package/template/console/reports/controller.ts +96 -49
  49. package/template/console/reports/events.ts +9 -2
  50. package/template/console/reports/load-inbox.ts +17 -0
  51. package/template/console/reports/private-drafts.ts +32 -0
  52. package/template/console/reports/store.ts +2 -2
  53. package/template/console/reports/types.ts +41 -2
  54. package/template/console/reports/use-editing-access.ts +13 -0
  55. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  56. package/template/console/shell/WorkspaceShell.tsx +55 -29
  57. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  58. package/template/console/shell/controller.ts +22 -8
  59. package/template/console/shell/navigation.ts +30 -39
  60. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  61. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  62. package/template/console/workflows/PlaybookFields.tsx +109 -0
  63. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  64. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  65. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  66. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  67. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  68. package/template/console/workflows/templates.ts +3 -3
  69. package/template/console/workflows/types.ts +11 -0
  70. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  71. package/template/migrations/0003_action_confirmation.sql +24 -0
  72. package/template/migrations/0004_notice_recovery.sql +22 -0
  73. package/template/migrations/0005_appeal_continuity.sql +17 -0
  74. package/template/migrations/0006_case_ownership.sql +23 -0
  75. package/template/migrations/0007_integration_verification.sql +21 -0
  76. package/template/migrations/0008_case_escalations.sql +135 -0
  77. package/template/migrations/0009_participant_continuation.sql +61 -0
  78. package/template/migrations/0010_appeal_corrections.sql +41 -0
  79. package/template/npm-shrinkwrap.json +8036 -0
  80. package/template/package.json +25 -17
  81. package/template/public/_headers +16 -1
  82. package/template/public/console/auth-shell.js +18701 -11800
  83. package/template/public/console/release.json +1 -0
  84. package/template/public/console/workspace-features.css +2 -0
  85. package/template/public/embed/embed.css +3 -0
  86. package/template/public/embed/embed.js +19 -1
  87. package/template/public/embed/index.html +2 -0
  88. package/template/public/index.html +9 -6
  89. package/template/public/participant.css +2 -0
  90. package/template/public/participant.html +13 -0
  91. package/template/public/participant.js +111 -0
  92. package/template/public/public-report.js +21 -1
  93. package/template/public/report/index.html +1 -1
  94. package/template/public/styles.css +329 -10
  95. package/template/public/workspace.css +124 -0
  96. package/template/scripts/reports-plan.mjs +1 -1
  97. package/template/shared/case-escalations.ts +31 -0
  98. package/template/shared/integration-progress.ts +27 -0
  99. package/template/shared/notice-inputs.ts +3 -0
  100. package/template/shared/playbook-resources.ts +14 -0
  101. package/template/shared/playbooks.ts +249 -0
  102. package/template/src/agent-executor.ts +25 -8
  103. package/template/src/ai-observability.ts +14 -13
  104. package/template/src/ai-registry-validation.ts +1 -1
  105. package/template/src/appeal-continuity.ts +75 -0
  106. package/template/src/appeal-correction-state.ts +46 -0
  107. package/template/src/appeal-corrections.ts +112 -0
  108. package/template/src/appeal-decisions.ts +85 -0
  109. package/template/src/assistant.ts +3 -4
  110. package/template/src/case-escalations.ts +223 -0
  111. package/template/src/case-investigator.ts +179 -0
  112. package/template/src/component-executor.ts +3 -2
  113. package/template/src/configuration-registry.ts +4 -1
  114. package/template/src/database-readiness.ts +8 -3
  115. package/template/src/human-tasks.ts +152 -55
  116. package/template/src/index.ts +178 -18
  117. package/template/src/installation-admin.ts +2 -1
  118. package/template/src/integration-progress.ts +45 -0
  119. package/template/src/integration-verification.ts +249 -0
  120. package/template/src/investigation-evidence.ts +95 -0
  121. package/template/src/investigator-fixture.ts +30 -0
  122. package/template/src/notice-delivery.ts +19 -0
  123. package/template/src/notice-recovery.ts +81 -0
  124. package/template/src/operator-scope.ts +31 -0
  125. package/template/src/participant-access.ts +163 -0
  126. package/template/src/participant-case.ts +87 -0
  127. package/template/src/participant-customer-access.ts +93 -0
  128. package/template/src/participant-recovery.ts +43 -0
  129. package/template/src/participant-routes.ts +67 -0
  130. package/template/src/playbook-resources.ts +58 -0
  131. package/template/src/report-admin.ts +9 -2
  132. package/template/src/report-auth.ts +1 -28
  133. package/template/src/report-delivery.ts +136 -129
  134. package/template/src/report-email-template.ts +5 -2
  135. package/template/src/report-governance.ts +42 -45
  136. package/template/src/report-operations.ts +16 -63
  137. package/template/src/report-ownership.ts +64 -0
  138. package/template/src/report-repository.ts +16 -7
  139. package/template/src/report-types.ts +1 -0
  140. package/template/src/report-workflow.ts +181 -274
  141. package/template/src/workflow-actions.ts +248 -156
  142. package/template/src/workflow-compiler.ts +63 -12
  143. package/template/src/workflow-dynamic-runtime.ts +18 -7
  144. package/template/src/workflow-effects.ts +95 -18
  145. package/template/src/workflow-event-transport.ts +16 -0
  146. package/template/src/workflow-platform-types.ts +3 -0
  147. package/template/src/workflow-platform-validation.ts +23 -1
  148. package/template/src/workflow-repository.ts +131 -28
  149. package/template/src/workflow-runs.ts +9 -4
  150. package/template/src/workflow-simulator.ts +7 -4
  151. package/template/src/workspace-governance.ts +6 -4
  152. package/template/tsconfig.json +1 -0
  153. package/template/vite.console.config.ts +30 -2
  154. package/template/worker-configuration.d.ts +3 -2
@@ -0,0 +1,54 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
3
+ import { workflowEvents } from "../workflows/events";
4
+ import type { IntegrationVerificationList, IntegrationVerification as Check } from "../../shared/integration-progress";
5
+ import { dateTime } from "../lib/format";
6
+ import { errorMessage, postJson, requestJson } from "../lib/http";
7
+ import { dispatchOpenReport } from "../reports/events";
8
+ import { settingsEvents } from "../settings/events";
9
+ import { registryEvents } from "./events";
10
+ import "./integration-verification.css";
11
+
12
+ const stateLabel: Record<Check['state'],string> = { awaiting_case:"Waiting for a fresh report",in_progress:"Check in progress",blocked:"Needs attention",stale:"Configuration changed",verified:"Selected outcome verified" };
13
+ export function IntegrationVerification() {
14
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
15
+ const [data,setData] = useState<IntegrationVerificationList|null>(null), [error,setError] = useState(''), [busy,setBusy] = useState(false);
16
+ const [integration,setIntegration] = useState(''), [form,setForm] = useState(''), [queue,setQueue] = useState(''), [decision,setDecision] = useState('');
17
+ const [activeId,setActiveId] = useState(''), [candidate,setCandidate] = useState('');
18
+ const generation = useRef(0);
19
+ const load = useCallback(async()=>{const version=++generation.current;setBusy(true);setError('');try {const result=await requestJson<IntegrationVerificationList>('/v1/admin/integration-verifications');if(version===generation.current)setData(result);}catch(cause){if(version===generation.current)setError(errorMessage(cause,'Verification could not refresh.'));}finally{if(version===generation.current)setBusy(false);}},[]);
20
+ useEffect(()=>{if(shell.currentView==='components')void load();return()=>{generation.current++;};},[load,shell.currentView]);
21
+ useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);window.addEventListener(workflowEvents.changed,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);window.removeEventListener(workflowEvents.changed,refresh);};},[load]);
22
+ const choices=data?.choices;
23
+ const selectedIntegration=choices?.integrations.find(item=>item.id===integration);
24
+ const selectedPlaybook=choices?.playbooks.find(item=>item.queueId===queue);
25
+ const selectedForm=selectedIntegration?.formVersionId||form;
26
+ const active=data?.runs.find(item=>item.id===activeId)||data?.runs[0];
27
+ useEffect(()=>{setCandidate('');},[active?.id]);
28
+ useEffect(()=>{if(choices?.integrations.length===1&&!integration)setIntegration(choices.integrations[0]!.id);if(choices?.playbooks.length===1&&!queue)setQueue(choices.playbooks[0]!.queueId);},[choices,integration,queue]);
29
+ const save = async(path:string,body:unknown)=>{const version=++generation.current;setBusy(true);setError('');try {const result=await postJson<Check>(path,body);if(version!==generation.current)return;setActiveId(result.id);setData(previous=>previous?{...previous,runs:[result,...previous.runs.filter(item=>item.id!==result.id)].slice(0,5)}:previous);}catch(cause){if(version===generation.current)setError(errorMessage(cause,'The check could not be saved.'));}finally{if(version===generation.current)setBusy(false);}};
30
+ return <section className="integration-verification surface" aria-label="Verify a customer connection">
31
+ <div className="surface-head"><div><h2>Verify one complete customer journey</h2><p>Follow one fresh case from your reporting channel to a confirmed outcome using the current configuration.</p></div><button type="button" className="secondary" disabled={busy} onClick={()=>void load()}>{busy?'Checking…':'Refresh verification'}</button></div>
32
+ <form className="verification-start" onSubmit={event=>{event.preventDefault();void save('/v1/admin/integration-verifications',{integration_id:integration,form_version_id:selectedForm,queue_id:queue,decision_code:decision});}}>
33
+ <label>Reporting integration<select value={integration} onChange={event=>{setIntegration(event.target.value);setForm('');}} required disabled={busy}><option value="">Choose an integration</option>{choices?.integrations.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
34
+ <label>Reporting form<select value={selectedForm} onChange={event=>setForm(event.target.value)} required disabled={busy||Boolean(selectedIntegration?.formVersionId)}><option value="">Choose a form</option>{choices?.forms.filter(item=>!selectedIntegration?.formVersionId||item.id===selectedIntegration.formVersionId).map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
35
+ <label>Queue and active playbook<select value={queue} onChange={event=>{setQueue(event.target.value);setDecision('');}} required disabled={busy}><option value="">Choose a queue</option>{choices?.playbooks.map(item=><option key={item.queueId} value={item.queueId}>{item.queueName} · {item.name}</option>)}</select></label>
36
+ <label>Outcome to verify<select value={decision} onChange={event=>setDecision(event.target.value)} required disabled={busy||!selectedPlaybook}><option value="">Choose an outcome</option>{selectedPlaybook?.decisionCodes.map(code=><option key={code} value={code}>{code.replaceAll('_',' ')}</option>)}</select></label>
37
+ <p className="form-help">Use a safe test account and target. Starting a check changes no customer data. The case you submit afterward follows normal action approvals and participant delivery.</p>
38
+ <button className="primary" type="submit" disabled={busy||!integration||!selectedForm||!queue||!decision}>Start a new verification</button>
39
+ </form>
40
+ {data&&!choices?.integrations.length?<p className="case-guidance">Set up a reporting integration before starting a check.</p>:null}
41
+ {data&&!choices?.playbooks.length?<p className="case-guidance">Publish and activate a primary playbook for an accessible queue first.</p>:null}
42
+ {data&&data.runs.length>1?<label className="verification-history">Recent checks<select value={active?.id||''} onChange={event=>setActiveId(event.target.value)}>{data.runs.map(run=><option key={run.id} value={run.id}>{run.integrationName} · {run.decisionCode.replaceAll('_',' ')} · {dateTime(run.startedAt)}</option>)}</select></label>:null}
43
+ {active?<article className="verification-run" aria-label="Current verification" aria-busy={busy}>
44
+ <div className="verification-summary"><h3>{active.integrationName} · {active.decisionCode.replaceAll('_',' ')}</h3><strong className={`verification-state verification-state-${error?'blocked':active.state}`} role="status">{error?'Verification unavailable':stateLabel[active.state]}</strong><p>{active.formName} → {active.queueName} → {active.playbookName}</p><small>Started {dateTime(active.startedAt)}</small></div>
45
+ {active.localSandbox?<p className="case-guidance">Local sandbox check. These records do not verify production providers or delivery.</p>:null}
46
+ {active.state==='stale'?<p className="case-guidance">The configuration changed since this check started. Start a new verification, then submit another fresh report. These results cannot verify the current setup.</p>:null}
47
+ {active.report?<button className="secondary" type="button" onClick={()=>dispatchOpenReport(active.report!.id)}>Open case {active.report.reference}</button>:null}
48
+ {active.state==='awaiting_case'?<div className="verification-bind"><p>Submit a report through <strong>{active.integrationName}</strong> using <strong>{active.formName}</strong>, with an example that should reach <strong>{active.queueName}</strong> and the selected outcome. Refresh after the report reaches its playbook.</p>{active.candidates.length?<><label>Fresh report to follow<select value={candidate} onChange={event=>setCandidate(event.target.value)} disabled={busy}><option value="">Choose the report you submitted</option>{active.candidates.map(item=><option key={item.id} value={item.id}>{item.reference} · {dateTime(item.receivedAt)}</option>)}</select></label><button type="button" disabled={busy||!candidate} onClick={()=>void save(`/v1/admin/integration-verifications/${active.id}/bind`,{report_id:candidate})}>Follow this case</button></>:<p className="form-help">No matching fresh case has reached this queue yet. If a report is missing, check its routing in Cases. Earlier reports cannot verify this check.</p>}</div>:null}
49
+ <ol className="verification-evidence">{active.stages.map(item=><li key={item.key} data-state={item.state}><div><strong>{item.title}</strong><span>{item.state==='complete'?'Confirmed':item.state==='not_required'?'Not required':item.state==='blocked'?'Needs attention':'Waiting'}</span></div><p>{item.detail}</p></li>)}</ol>
50
+ <p className="form-help">This check covers this integration, form, queue, playbook and selected outcome only. Verify other customer action paths separately. Status is rechecked against current configuration whenever you refresh.</p>
51
+ </article>:null}
52
+ {error?<p role="alert" className="error">{error} {data?'Refresh before relying on previously displayed results.':''}</p>:null}
53
+ </section>;
54
+ }
@@ -239,7 +239,7 @@ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChange
239
239
  <button className="danger-link" type="button" disabled={Boolean(pending) || dependencyList.length > 0} onClick={() => setConfirmArchive(true)}>Archive {kind === "component" ? "tool" : "connection"}</button>
240
240
  </div>
241
241
  {dependencyList.length ? <p className="registry-blocked-copy">Archive is unavailable until the dependencies above are retired or moved to another version.</p> : null}
242
- {confirmArchive ? <div className="registry-archive-confirm" role="alert"><div><strong>Archive {item.name}?</strong><p>It will disappear from normal lists and cannot be restored. Version and audit records will remain.</p></div><div><button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => setConfirmArchive(false)}>Cancel</button><button className="danger" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle("archive")}>{pending === "archive" ? "Archiving…" : "Archive permanently"}</button></div></div> : null}
242
+ {confirmArchive ? <div className="registry-archive-confirm" role="alert"><div><strong>Archive {item.name}?</strong><p>It will disappear from normal lists and cannot be restored. Version and audit records will remain.</p></div><div><button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => setConfirmArchive(false)}>Cancel</button><button className="danger-action" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle("archive")}>{pending === "archive" ? "Archiving…" : "Archive permanently"}</button></div></div> : null}
243
243
  </section> : null}
244
244
  </>}
245
245
  <p className="error" role="alert">{error}</p>
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent, type MouseEvent } from "react";
2
2
  import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
3
3
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
4
  import { announceRegistryChanged, registryEvents } from "./events";
@@ -80,12 +80,16 @@ export function RegistryDialog() {
80
80
  const [paths, setPaths] = useState("/");
81
81
  const [credential, setCredential] = useState<CredentialStrategy>("none");
82
82
  const [secretReference, setSecretReference] = useState("");
83
+ const [correctionEnabled, setCorrectionEnabled] = useState(false);
84
+ const [correctionCode, setCorrectionCode] = useState("");
85
+ const [correctsCodes, setCorrectsCodes] = useState("");
83
86
 
84
87
  const reset = (): void => {
85
88
  setStep(0); setName(""); setKey(""); setKeyEdited(false); setDescription(""); setPurpose("enrichment");
86
89
  setImplementation("external_api"); setReference("POST /"); setConnectionVersionId(""); setRisk("low");
87
90
  setInputFields("report.id, report.reason_code"); setClassifications("report_operational"); setInputSchema(inputSchemaDefault); setOutputSchema(outputSchemaDefault);
88
91
  setFixtureInput(fixtureInputDefault); setFixtureOutput(fixtureOutputDefault);
92
+ setCorrectionEnabled(false); setCorrectionCode(""); setCorrectsCodes("");
89
93
  setConnectionKind("external_api"); setTargetMode("url"); setBaseUrl(""); setServiceBinding(""); setMethods("POST"); setPaths("/"); setCredential("none"); setSecretReference(""); setError("");
90
94
  };
91
95
  const close = (): void => { if (!pending) { setKind(null); reset(); } };
@@ -127,11 +131,20 @@ export function RegistryDialog() {
127
131
 
128
132
  const selectPurpose = (next: ToolPurpose): void => {
129
133
  setPurpose(next);
134
+ if (next !== "action") setCorrectionEnabled(false);
130
135
  if (next === "enrichment") { setImplementation("external_api"); setReference("POST /"); setRisk("low"); }
131
136
  if (next === "action") { setImplementation("external_api"); setReference("POST /"); setRisk("high"); }
132
137
  if (next === "ai") { setImplementation("workers_ai"); setReference("@cf/meta/llama-3.1-8b-instruct"); setRisk("medium"); }
133
138
  };
134
139
 
140
+ const useCorrectionContract = (): void => {
141
+ setInputFields("reason, original_operation_id");
142
+ setInputSchema(JSON.stringify({ type: "object", properties: { reason: { type: "string", minLength: 1, maxLength: 2000 }, original_operation_id: { type: "string", minLength: 1 } }, required: ["reason", "original_operation_id"], additionalProperties: false }, null, 2));
143
+ setFixtureInput(JSON.stringify({ reason: "An independent appeal reversed the original decision.", original_operation_id: "example-original-operation" }, null, 2));
144
+ setOutputSchema(JSON.stringify({ type: "object", properties: { action_id: { type: "string" }, operation_id: { type: "string" }, payload_digest: { type: "string" }, status: { type: "string", enum: ["accepted", "applied", "failed"] } }, required: ["action_id", "operation_id", "payload_digest", "status"] }, null, 2));
145
+ setFixtureOutput(JSON.stringify({ action_id: "example-correction", operation_id: "example-correction-operation", payload_digest: `sha256:${"a".repeat(64)}`, status: "applied" }, null, 2));
146
+ };
147
+
135
148
  const changeImplementation = (next: ImplementationKind): void => {
136
149
  setImplementation(next); setConnectionVersionId("");
137
150
  if (["webhook", "external_api"].includes(next)) setReference("POST /");
@@ -156,6 +169,9 @@ export function RegistryDialog() {
156
169
  if (kind === "component") {
157
170
  if (!reference.trim()) { setError(`Enter the ${referenceLabel(implementation).toLowerCase()}.`); return false; }
158
171
  if (needsConnection && !connectionVersionId) { setError("Select the connection this tool should use."); return false; }
172
+ if (isAction && correctionEnabled && (!/^[a-z][a-z0-9_:-]{0,99}$/u.test(correctionCode) || !commaValues(correctsCodes).length || !commaValues(correctsCodes).every(code => /^[a-z][a-z0-9_:-]{0,99}$/u.test(code)))) {
173
+ setError("Enter the correction action code and the original action codes it can correct."); return false;
174
+ }
159
175
  if (!commaValues(inputFields).length || !commaValues(classifications).length) { setError("Add at least one allowed input field and data classification."); return false; }
160
176
  try {
161
177
  jsonObject(inputSchema, "Input schema"); jsonObject(outputSchema, "Output schema");
@@ -169,7 +185,12 @@ export function RegistryDialog() {
169
185
  return true;
170
186
  };
171
187
 
172
- const next = (): void => { if (validateStep()) setStep((current) => Math.min(2, current + 1)); };
188
+ const next = (event: MouseEvent<HTMLButtonElement>): void => {
189
+ // Prevent the same click from submitting if React changes Continue into
190
+ // the submit button while rendering the final review step.
191
+ event.preventDefault();
192
+ if (validateStep()) setStep((current) => Math.min(2, current + 1));
193
+ };
173
194
  const back = (): void => { setError(""); setStep((current) => Math.max(0, current - 1)); };
174
195
 
175
196
  const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
@@ -200,7 +221,7 @@ export function RegistryDialog() {
200
221
  connection_version_id: needsConnection ? connectionVersionId : null, risk_class: risk,
201
222
  timeout_policy: { timeout_ms: 5000 }, retry_policy: { maximum_attempts: isAction ? 5 : 3, backoff: "exponential" },
202
223
  cache_policy: { enabled: !isAction }, concurrency_policy: { maximum_concurrency: 8 }, budget_policy: {},
203
- approval_policy: isAction ? { mode: "human" } : {}, tool_manifest: [],
224
+ approval_policy: isAction ? { mode: "human", ...(correctionEnabled ? { correction: { action_code: correctionCode, corrects_action_codes: commaValues(correctsCodes) } } : {}) } : {}, tool_manifest: [],
204
225
  compatibility: { fixture_evidence: { fixtures: [{ name: "Contract example", input: jsonObject(fixtureInput, "Example input"), expected_output: jsonObject(fixtureOutput, "Example output"), expected_status: "succeeded", mode: "simulated" }] } },
205
226
  },
206
227
  }),
@@ -218,6 +239,15 @@ export function RegistryDialog() {
218
239
  <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>
219
240
  {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}
220
241
  <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>
242
+ {isAction ? <label className="wide-field"><span><input type="checkbox" checked={correctionEnabled} onChange={event => {
243
+ setCorrectionEnabled(event.currentTarget.checked);
244
+ if (event.currentTarget.checked && inputSchema === inputSchemaDefault && outputSchema === outputSchemaDefault) useCorrectionContract();
245
+ }} /> Use for appeal corrections</span><small>Only an explicitly approved appeal correction can use this capability to correct an earlier customer action.</small></label> : null}
246
+ {isAction && correctionEnabled ? <>
247
+ <label>Correction action code<input value={correctionCode} maxLength={100} placeholder="restore_message" onChange={event => setCorrectionCode(event.currentTarget.value)} /><small>The action code accepted by your customer endpoint.</small></label>
248
+ <label>Original action codes<input value={correctsCodes} maxLength={1000} placeholder="hide_message" onChange={event => setCorrectsCodes(event.currentTarget.value)} /><small>Comma-separated codes this capability is allowed to correct.</small></label>
249
+ <div className="wide-field"><p>The correction pins the original operation automatically. Reviewers provide the reason and any additional typed fields in your contract.</p><button type="button" className="secondary" onClick={useCorrectionContract}>Use standard correction contract</button><small>Replaces the input/output schemas and examples with the reason, original operation and signed receipt contract.</small></div>
250
+ </> : null}
221
251
  <label>Data classifications<input value={classifications} onChange={(event) => setClassifications(event.currentTarget.value)} /><small>Comma-separated labels used for governance.</small></label>
222
252
  <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>
223
253
  </div>
@@ -243,7 +273,7 @@ export function RegistryDialog() {
243
273
  {kind === "component" && step === 1 ? <><div className="registry-step-heading"><h3>Configure the {purpose}</h3><p>Choose where it runs and disclose only the data it needs.</p></div>{componentSetup}</> : null}
244
274
  {kind === "connection" && step === 0 ? <><div className="registry-step-heading"><h3>Where should tools connect?</h3><p>A connection secures one destination. Multiple tools can reuse it.</p></div><div className="form-grid"><label>Name<input ref={nameRef} maxLength={160} value={name} onChange={(event) => { const value = event.currentTarget.value; setName(value); if (!keyEdited) setKey(slug(value)); }} /></label><label>Key<input maxLength={100} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label><label>Connection type<select value={connectionKind} onChange={(event) => setConnectionKind(event.currentTarget.value as ConnectionKind)}><option value="external_api">External API</option><option value="customer_worker">Customer Worker</option><option value="webhook">Webhook</option><option value="ai_provider">AI provider</option><option value="notification">Notification</option><option value="email">Email</option></select></label><label>Destination type<select value={targetMode} onChange={(event) => changeTargetMode(event.currentTarget.value as "url" | "service")}><option value="url">HTTPS endpoint</option><option value="service">Worker service binding</option></select></label>{targetMode === "url" ? <label className="wide-field">HTTPS origin<input type="url" placeholder="https://api.example.com" value={baseUrl} onChange={(event) => setBaseUrl(event.currentTarget.value)} /><small>Origin only; paths are allowlisted in the next step.</small></label> : <label className="wide-field">Service binding<input placeholder="CUSTOMER_API" value={serviceBinding} onChange={(event) => setServiceBinding(event.currentTarget.value)} /></label>}<label className="wide-field">Description<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.currentTarget.value)} /></label></div></> : null}
245
275
  {kind === "connection" && step === 1 ? <><div className="registry-step-heading"><h3>Limit access and configure authentication</h3><p>Use the least authority this connection needs.</p></div>{connectionSecurity}</> : null}
246
- {step === 2 ? <><div className="registry-step-heading"><h3>Review and create</h3><p>Confirm the security boundary before publishing version 1.</p></div><section className="registry-review"><dl><div><dt>Name</dt><dd>{name}</dd></div><div><dt>Type</dt><dd>{kind === "component" ? purposeOptions.find((option) => option.value === purpose)?.title : connectionKind.replaceAll("_", " ")}</dd></div><div><dt>{kind === "component" ? "Authority" : "Destination"}</dt><dd>{kind === "component" ? isAction ? "Can change customer data · approval required" : "Read-only" : targetMode === "url" ? baseUrl : serviceBinding}</dd></div><div><dt>{kind === "component" ? "Runtime" : "Access"}</dt><dd>{kind === "component" ? needsConnection ? activeConnections.find((connection) => connection.activeVersionId === connectionVersionId)?.name || "No connection" : implementation.replaceAll("_", " ") : `${methods} · ${paths}`}</dd></div></dl>{kind === "connection" && needsSecret ? <div className="registry-secret-next"><strong>Install the credential after creation</strong><p>From the Resolve project directory, run this command and paste the secret value when Wrangler asks. The value is never entered in this UI.</p><code>npx wrangler secret put {secretReference}</code></div> : null}<div className="registry-review-note"><strong>What happens next</strong><p>{kind === "component" ? "Resolve publishes version 1 and runs its stored contract example. Add the tool to a workflow when you are ready." : `Resolve publishes the connection definition.${needsSecret ? " Install the Worker secret shown above, then" : " Then"} create a tool that uses it and send an explicit live test request from the connection details.`}</p></div></section></> : null}
276
+ {step === 2 ? <><div className="registry-step-heading"><h3>Review and create</h3><p>Confirm the security boundary before publishing version 1.</p></div><section className="registry-review"><dl><div><dt>Name</dt><dd>{name}</dd></div><div><dt>Type</dt><dd>{kind === "component" ? purposeOptions.find((option) => option.value === purpose)?.title : connectionKind.replaceAll("_", " ")}</dd></div><div><dt>{kind === "component" ? "Authority" : "Destination"}</dt><dd>{kind === "component" ? isAction ? "Can change customer data · approval required" : "Read-only" : targetMode === "url" ? baseUrl : serviceBinding}</dd></div><div><dt>{kind === "component" ? "Runtime" : "Access"}</dt><dd>{kind === "component" ? needsConnection ? activeConnections.find((connection) => connection.activeVersionId === connectionVersionId)?.name || "No connection" : implementation.replaceAll("_", " ") : `${methods} · ${paths}`}</dd></div>{kind === "component" && isAction && correctionEnabled ? <div><dt>Appeal correction</dt><dd>{correctionCode} corrects {commaValues(correctsCodes).join(", ")}</dd></div> : null}</dl>{kind === "connection" && needsSecret ? <div className="registry-secret-next"><strong>Install the credential after creation</strong><p>From the Resolve project directory, run this command and paste the secret value when Wrangler asks. The value is never entered in this UI.</p><code>npx wrangler secret put {secretReference}</code></div> : null}<div className="registry-review-note"><strong>What happens next</strong><p>{kind === "component" ? isAction && correctionEnabled ? "Resolve publishes version 1 and checks its stored contract example. Independent appeal reviewers can then approve this correction for the original action types listed above." : "Resolve publishes version 1 and runs its stored contract example. Add the tool to a workflow when you are ready." : `Resolve publishes the connection definition.${needsSecret ? " Install the Worker secret shown above, then" : " Then"} create a tool that uses it and send an explicit live test request from the connection details.`}</p></div></section></> : null}
247
277
  </div>
248
278
  <div className="editor-footer registry-wizard-footer"><p className="error" role="alert">{error}</p><div>{step === 0 ? <button className="secondary" type="button" disabled={pending} onClick={close}>Cancel</button> : <button className="secondary" type="button" disabled={pending} onClick={back}>Back</button>}{step < 2 ? <button className="primary" type="button" onClick={next}>Continue</button> : <button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : kind === "component" ? "Create tool" : "Add connection"}</button>}</div></div>
249
279
  </form>
@@ -5,6 +5,7 @@ import { errorMessage, requestJson } from "../lib/http";
5
5
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
6
6
  import { openRegistryEditor, registryEvents } from "./events";
7
7
  import { RegistryDetailsDialog } from "./RegistryDetailsDialog";
8
+ import { IntegrationSetup } from "./IntegrationSetup";
8
9
  import type { ComponentListResponse, ComponentSummary, ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
9
10
 
10
11
  type RegistryItem = ComponentSummary | ConnectionSummary;
@@ -38,8 +39,10 @@ function RegistryRow({ item, kind, onOpen }: { item: RegistryItem; kind: Registr
38
39
  const category = component ? toolCategory(component) : "";
39
40
  const iconClass = connection ? "connection" : category === "action" ? "action" : category === "ai" ? "agent" : "";
40
41
  const icon = connection ? "↗" : category === "action" ? "⚡" : category === "ai" ? "✦" : category === "message" ? "✉" : "+";
41
- const status = lifecycleLabel(item.status || "active");
42
- const statusClass = item.status === "active" ? "available" : item.status === "archived" ? "archived" : "paused";
42
+ const status = connection && item.status === "active"
43
+ ? connection.healthState === "healthy" ? "Last call succeeded" : connection.healthState === "degraded" ? "Recent failures" : connection.healthState === "open_circuit" ? "Temporarily blocked" : connection.healthState === "paused" ? "Paused" : "Not verified yet"
44
+ : lifecycleLabel(item.status || "active");
45
+ const statusClass = item.status === "active" && (!connection || connection.healthState === "healthy") ? "available" : item.status === "archived" ? "archived" : "paused";
43
46
  const destination = connection?.baseUrl || connection?.serviceBindingName || "Destination not configured";
44
47
  return <article className="registry-resource-row">
45
48
  <span className={`object-icon ${iconClass}`} aria-hidden="true">{icon}</span>
@@ -109,12 +112,14 @@ export function RegistryWorkspace() {
109
112
  };
110
113
 
111
114
  return <div className="page-stack registry-workspace" data-react-slice="registry-workspace">
112
- <PageHeader titleId="components-title" title="Tools & connections" description="Control what workflows can do, which data they can use, and where requests may go.">
115
+ <PageHeader titleId="components-title" title="Connections" description="Connect your customer application and define what Safest can read, request, and confirm.">
113
116
  <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button>{canCreate ? <button className="primary" type="button" onClick={() => openRegistryEditor(currentKind)}>{tab === "tools" ? "Create tool" : "Add connection"}</button> : null}</div>
114
117
  </PageHeader>
115
118
 
116
- <section className="registry-model" aria-label="How workflows, tools, and connections work together">
117
- <div><span>1</span><strong>Workflow</strong><small>Chooses when work runs</small></div><b aria-hidden="true">→</b><div><span>2</span><strong>Tool</strong><small>Reads context or takes an action</small></div><b aria-hidden="true">→</b><div><span>3</span><strong>Connection</strong><small>Secures access to a service</small></div><b aria-hidden="true">→</b><div><span>4</span><strong>Your service</strong><small>Returns data or applies a change</small></div>
119
+ {canManage?<IntegrationSetup onTools={kind=>{setTab("tools");setFilter(kind);setSearch("");document.querySelector(".registry-tabs-row")?.scrollIntoView({block:"start",behavior:"smooth"});}} />:null}
120
+
121
+ <section className="registry-model" aria-label="How playbooks use customer capabilities">
122
+ <div><span>1</span><strong>Playbook</strong><small>Defines the permitted work</small></div><b aria-hidden="true">→</b><div><span>2</span><strong>Tool</strong><small>Reads context or takes an action</small></div><b aria-hidden="true">→</b><div><span>3</span><strong>Connection</strong><small>Secures access to a service</small></div><b aria-hidden="true">→</b><div><span>4</span><strong>Your service</strong><small>Returns data or applies a change</small></div>
118
123
  </section>
119
124
 
120
125
  <div className="registry-tabs-row"><div className="segmented-tabs" role="tablist" aria-label="Tools and connections"><button className={tab === "tools" ? "active" : ""} type="button" role="tab" aria-selected={tab === "tools"} onClick={() => { setTab("tools"); setSearch(""); }}>Tools <span>{components.filter((item) => item.status !== "archived").length}</span></button><button className={tab === "connections" ? "active" : ""} type="button" role="tab" aria-selected={tab === "connections"} onClick={() => { setTab("connections"); setSearch(""); }}>Connections <span>{connections.filter((item) => item.status !== "archived").length}</span></button></div><p>{tab === "tools" ? "Reusable capabilities placed in workflows. Enrichments are read-only; actions require approval." : "Reusable, allowlisted routes to APIs and Workers. Credential values stay in Worker secrets."}</p></div>
@@ -0,0 +1,25 @@
1
+ .integration-verification { min-width: 0; padding: 24px; }
2
+ .integration-verification > .surface-head { padding: 0 0 20px; margin-bottom: 20px; }
3
+ .verification-start { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }
4
+ .verification-start label, .verification-bind label, .verification-history { display: grid; gap: 8px; min-width: 0; font-weight: 600; font-size: .875rem; }
5
+ .verification-start select, .verification-bind select, .verification-history select { width: 100%; min-width: 0; min-height: 44px; font-size: 1rem; }
6
+ .verification-start .form-help { grid-column: 1 / -1; margin: 0; }
7
+ .verification-start button { justify-self: start; }
8
+ .verification-history, .verification-run { margin-top: 24px; }
9
+ .verification-run { border-top: 1px solid var(--border, #d8e0da); padding-top: 20px; }
10
+ .verification-summary h3, .verification-summary p { margin: 0 0 8px; overflow-wrap: anywhere; }
11
+ .verification-summary small { display: block; margin-bottom: 16px; }
12
+ .verification-state { display: inline-block; border: 1px solid currentColor; border-radius: 6px; padding: 4px 8px; margin: 4px 0 12px; }
13
+ .verification-state-verified { color: #235a43; }
14
+ .verification-state-blocked, .verification-state-stale { color: #8a391c; }
15
+ .verification-bind { display: grid; gap: 12px; margin: 16px 0; }
16
+ .verification-bind p { margin: 0; }
17
+ .verification-bind button { justify-self: start; }
18
+ .verification-evidence { list-style: none; padding: 0; margin: 20px 0; display: grid; gap: 12px; }
19
+ .verification-evidence li { border: 1px solid var(--border, #d8e0da); border-radius: 8px; padding: 14px; }
20
+ .verification-evidence li > div { display: flex; gap: 8px 16px; justify-content: space-between; flex-wrap: wrap; }
21
+ .verification-evidence li span { font-size: .875em; }
22
+ .verification-evidence li p { margin: 8px 0 0; overflow-wrap: anywhere; }
23
+ .verification-evidence li[data-state=complete] { border-left: 4px solid #377457; }
24
+ .verification-evidence li[data-state=blocked] { border-left: 4px solid #a64a25; }
25
+ @media(max-width: 600px) { .integration-verification { padding: 16px; } .integration-verification > .surface-head { align-items: stretch; flex-direction: column; gap: 16px; } .verification-start { grid-template-columns: minmax(0,1fr); } .verification-start button, .verification-bind button { width: 100%; } .verification-evidence li { padding: 12px; } }
@@ -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
+ }