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
@@ -2,9 +2,15 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEv
2
2
  import { readable } from "../lib/format";
3
3
  import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
4
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
5
+ import { dispatchNavigation } from "../shell/events";
5
6
  import { announceWorkflowChanged, workflowEvents } from "./events";
6
7
  import { insertWorkflowNode, removeWorkflowNode, workflowNodeChoices } from "./graph";
7
8
  import { WorkflowCanvas } from "./WorkflowCanvas";
9
+ import { compilePlaybook, playbookAuthority, type PlaybookGuide } from "../../shared/playbooks";
10
+ import { PlaybookExamples } from "./PlaybookExamples";
11
+ import { validateGuideInputs } from "./PlaybookFields";
12
+ import { PlaybookRuns, PlaybookVersions } from "./PlaybookHistory";
13
+ import { guideSteps, PlaybookGuideEditor, usePlaybookResources } from "./PlaybookGuideEditor";
8
14
  import type {
9
15
  JsonObject,
10
16
  QueueListItem,
@@ -56,12 +62,13 @@ function WorkflowConsole({ state }: { state: StudioConsoleState }) {
56
62
  return <><p><strong>{state.heading}</strong></p><p>{state.detail}</p></>;
57
63
  }
58
64
 
59
- function NodeInspector({ node, readOnly, busy, onApply, onRemove }: {
65
+ function NodeInspector({ node, readOnly, busy, onApply, onRemove, onDirty }: {
60
66
  node: WorkflowNode | null;
61
67
  readOnly: boolean;
62
68
  busy: boolean;
63
69
  onApply: (node: WorkflowNode) => Promise<void>;
64
70
  onRemove: (node: WorkflowNode) => Promise<void>;
71
+ onDirty: (dirty: boolean) => void;
65
72
  }) {
66
73
  const [name, setName] = useState("");
67
74
  const [description, setDescription] = useState("");
@@ -74,6 +81,8 @@ function NodeInspector({ node, readOnly, busy, onApply, onRemove }: {
74
81
  setName(node?.name || ""); setDescription(node?.description || ""); setErrorPort(node?.error_port || "");
75
82
  setMapping(JSON.stringify(node?.input_mapping || {}, null, 2)); setConfig(JSON.stringify(node?.config || {}, null, 2)); setError("");
76
83
  }, [node?.id, node?.name, node?.description, node?.error_port, node?.input_mapping, node?.config]);
84
+ const dirty = Boolean(node && !readOnly && (name !== (node.name || "") || description !== (node.description || "") || errorPort !== (node.error_port || "") || mapping !== JSON.stringify(node.input_mapping || {}, null, 2) || config !== JSON.stringify(node.config || {}, null, 2)));
85
+ useEffect(() => { onDirty(dirty); return () => onDirty(false); }, [dirty, onDirty]);
77
86
 
78
87
  if (!node) return <div className="node-inspector-empty"><h3>Select a step</h3><p>Review its inputs, retries, and failure path.</p></div>;
79
88
  const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
@@ -110,6 +119,18 @@ export function WorkflowStudio() {
110
119
  const [openWarning, setOpenWarning] = useState("");
111
120
  const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
112
121
  const [graph, setGraph] = useState<WorkflowGraph | null>(null);
122
+ const [guideDraft, setGuideDraft] = useState<PlaybookGuide | null>(null);
123
+ const [studioView, setStudioView] = useState<"guide" | "workflow" | "test" | "runs" | "versions">("workflow");
124
+ const [guideStep, setGuideStep] = useState(0);
125
+ const [lifecyclePending, setLifecyclePending] = useState(false);
126
+ const [exampleDirty, setExampleDirty] = useState(false);
127
+ const [nodeDirty, setNodeDirty] = useState(false);
128
+ const [loadEpoch, setLoadEpoch] = useState(0);
129
+ const [conflict, setConflict] = useState(false);
130
+ const [showCheckDetails, setShowCheckDetails] = useState<boolean | null>(null);
131
+ const savingRef = useRef(false);
132
+ const [lifecycleEpoch, setLifecycleEpoch] = useState(0);
133
+ const { resources, error: resourceError, reload: reloadResources } = usePlaybookResources(Boolean(workflowId));
113
134
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
114
135
  const [queues, setQueues] = useState<QueueListItem[]>([]);
115
136
  const [authorityMode, setAuthorityMode] = useState<WorkflowAuthorityMode>("assist");
@@ -126,23 +147,34 @@ export function WorkflowStudio() {
126
147
  const canPublish = shell.session?.permissions.publishWorkflows === true;
127
148
  const canPin = shell.session?.permissions.manageWorkflowPins === true;
128
149
  const readOnly = Boolean(workflow && !workflow.draft);
129
- const busy = loading || saving || Boolean(action);
150
+ const busy = loading || saving || Boolean(action) || lifecyclePending;
151
+ const guideDirty = JSON.stringify(guideDraft) !== JSON.stringify(graph?.playbook ?? null);
152
+ const graphReadOnly = readOnly || graph?.playbook?.editing_mode === "guided";
153
+ const graphJsonDirty = Boolean(advanced && !graphReadOnly && graph && graphJson !== JSON.stringify(graph, null, 2));
154
+ const unsaved = guideDirty || exampleDirty || nodeDirty || graphJsonDirty;
130
155
  const selectedNode = useMemo(() => graph?.nodes.find((node) => node.id === selectedNodeId) ?? null, [graph, selectedNodeId]);
131
156
 
132
157
  const clear = (): void => {
133
- setWorkflowId(null); setOpenWarning(""); setWorkflow(null); setGraph(null); setSelectedNodeId(null); setQueues([]); setLoading(false); setSaving(false); setAction(""); setError(""); setAdvanced(false); setGraphJson(""); setPinOpen(false);
158
+ setNodeDirty(false); setConflict(false); setExampleDirty(false); setLifecyclePending(false); setWorkflowId(null); setOpenWarning(""); setWorkflow(null); setGraph(null); setSelectedNodeId(null); setQueues([]); setLoading(false); setSaving(false); setAction(""); setError(""); setAdvanced(false); setGraphJson(""); setPinOpen(false);
134
159
  };
135
- const close = (): void => { if (!busy) clear(); };
160
+ const close = (): void => { if (!busy && (!unsaved || window.confirm("Discard the unsaved changes?"))) clear(); };
161
+ useEffect(() => setGuideDraft(graph?.playbook ?? null), [graph]);
162
+ useEffect(() => {
163
+ const unload = (event: BeforeUnloadEvent) => { if (workflowId && (unsaved || busy)) event.preventDefault(); };
164
+ window.addEventListener("beforeunload", unload);
165
+ return () => window.removeEventListener("beforeunload", unload);
166
+ }, [workflowId, unsaved, busy]);
136
167
 
137
168
  useEffect(() => {
138
169
  const openStudio = (event: Event): void => {
139
170
  if (!(event instanceof CustomEvent) || typeof event.detail?.workflowId !== "string") return;
171
+ if (busy || (unsaved && !window.confirm("Discard the unsaved changes before opening another playbook?"))) return;
140
172
  setOpenWarning(typeof event.detail.warning === "string" ? event.detail.warning : "");
141
173
  setWorkflowId(event.detail.workflowId);
142
174
  };
143
175
  window.addEventListener(workflowEvents.openStudio, openStudio);
144
176
  return () => window.removeEventListener(workflowEvents.openStudio, openStudio);
145
- }, []);
177
+ }, [busy, unsaved]);
146
178
  useEffect(() => {
147
179
  const dialog = dialogRef.current;
148
180
  if (!dialog) return;
@@ -150,13 +182,16 @@ export function WorkflowStudio() {
150
182
  if (!workflowId && dialog.open) dialog.close();
151
183
  }, [workflowId]);
152
184
  useEffect(() => {
153
- if (workflowId && shell.currentView !== "workflows" && !busy) clear();
154
- }, [shell.currentView, workflowId, busy]);
185
+ if (workflowId && shell.currentView !== "workflows" && !busy) {
186
+ if (unsaved && !window.confirm("Discard the unsaved playbook changes before leaving?")) dispatchNavigation("workflows");
187
+ else clear();
188
+ }
189
+ }, [shell.currentView, workflowId, busy, unsaved]);
155
190
  useEffect(() => {
156
191
  if (!workflowId) return;
157
192
  let active = true;
158
193
  const load = async (): Promise<void> => {
159
- setLoading(true); setError(""); setWorkflow(null); setGraph(null); setAdvanced(false); setPinOpen(false);
194
+ setLoading(true); setError(""); setConflict(false); setNodeDirty(false); setExampleDirty(false); setWorkflow(null); setGraph(null); setAdvanced(false); setPinOpen(false);
160
195
  try {
161
196
  const [response, queueResponse] = await Promise.all([
162
197
  requestJson<WorkflowResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}`),
@@ -173,18 +208,21 @@ export function WorkflowStudio() {
173
208
  if (!nextGraph) throw new Error("This workflow has no draft or published graph to open.");
174
209
  const availableQueues = (queueResponse.queues || []).filter((queue) => queue.status === "active");
175
210
  setWorkflow(detail); setGraph(structuredClone(nextGraph)); setGraphJson(JSON.stringify(nextGraph, null, 2));
211
+ setStudioView(nextGraph.playbook ? "guide" : "workflow"); setGuideStep(0);
176
212
  setSelectedNodeId(nextGraph.entry_node_id || nextGraph.nodes[0]?.id || null); setQueues(availableQueues);
177
- setPinQueueId(availableQueues[0]?.id || ""); setAuthorityMode(detail.activeVersion?.authorityMode || "assist");
213
+ setPinQueueId(nextGraph.playbook?.review.queue_id || availableQueues[0]?.id || ""); setAuthorityMode(nextGraph.playbook ? playbookAuthority(nextGraph.playbook) : detail.activeVersion?.authorityMode || "assist");
214
+ setPinMode(nextGraph.playbook?.rollout === "observe" ? "shadow" : "primary");
178
215
  setConsoleState(openWarning ? { kind: "message", heading: "Workflow created with a warning", detail: openWarning } : validationState(detail, "Stored validation"));
179
216
  } catch (cause) { if (active) setError(errorMessage(cause, "The workflow could not be opened.")); }
180
217
  finally { if (active) setLoading(false); }
181
218
  };
182
219
  void load();
183
220
  return () => { active = false; };
184
- }, [workflowId, openWarning]);
221
+ }, [workflowId, openWarning, loadEpoch]);
185
222
 
186
223
  const saveGraph = async (nextGraph: WorkflowGraph): Promise<boolean> => {
187
- if (!workflowId || !workflow?.draft || readOnly) return false;
224
+ if (!workflowId || !workflow?.draft || readOnly || savingRef.current) return false;
225
+ savingRef.current = true;
188
226
  setSaving(true); setError("");
189
227
  try {
190
228
  const result = await requestJson<WorkflowSaveResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/draft`, {
@@ -195,8 +233,8 @@ export function WorkflowStudio() {
195
233
  setConsoleState({ kind: "validation", heading: "Draft saved and checked", validation: result.validation });
196
234
  announceWorkflowChanged();
197
235
  return true;
198
- } catch (cause) { setError(errorMessage(cause, "The workflow draft could not be saved.")); return false; }
199
- finally { setSaving(false); }
236
+ } catch (cause) { setConflict(Boolean(errorResponse(cause, 409))); setError(errorMessage(cause, "The workflow draft could not be saved. Your changes are still here.")); return false; }
237
+ finally { savingRef.current = false; setSaving(false); }
200
238
  };
201
239
 
202
240
  const fetchValidation = async (): Promise<WorkflowValidationResponse | null> => {
@@ -211,16 +249,24 @@ export function WorkflowStudio() {
211
249
  };
212
250
  const fetchTest = async (): Promise<{ fixtureName: string; result: WorkflowTestResponse } | null> => {
213
251
  if (!workflowId) return null;
214
- const fixture = workflow?.fixtures?.[0];
215
- if (!fixture) throw new Error("Add a server-recorded fixture before publishing this workflow.");
216
- try {
217
- const result = await requestJson<WorkflowTestResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/test`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ fixture_id: fixture.id }) });
218
- return { fixtureName: fixture.name, result };
219
- } catch (cause) {
220
- const failed = errorResponse<WorkflowTestResponse>(cause, 422);
221
- if (failed?.status) return { fixtureName: fixture.name, result: failed };
222
- throw cause;
252
+ const saved = await requestJson<{ fixtures: { id: string; name: string }[] }>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/fixtures`);
253
+ const fixtures = graph?.playbook ? saved.fixtures : saved.fixtures.slice(0, 1);
254
+ if (!fixtures?.length) throw new Error("Add a server-recorded example before publishing this playbook.");
255
+ let last: { fixtureName: string; result: WorkflowTestResponse } | null = null;
256
+ let firstFailure: { fixtureName: string; result: WorkflowTestResponse } | null = null;
257
+ for (const fixture of fixtures) {
258
+ try {
259
+ const result = await requestJson<WorkflowTestResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/test`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ fixture_id: fixture.id }) });
260
+ last = { fixtureName: fixture.name, result };
261
+ } catch (cause) {
262
+ const failed = errorResponse<WorkflowTestResponse>(cause, 422);
263
+ if (failed?.status) { firstFailure ??= { fixtureName: fixture.name, result: failed }; last = { fixtureName: fixture.name, result: failed }; }
264
+ else throw cause;
265
+ }
223
266
  }
267
+ setLifecycleEpoch(value => value + 1);
268
+ if (firstFailure) return firstFailure;
269
+ return last ? { ...last, fixtureName: graph?.playbook ? `${fixtures.length} examples (last: ${last.fixtureName})` : last.fixtureName } : null;
224
270
  };
225
271
  const validate = async (): Promise<void> => {
226
272
  setAction("validate"); setError("");
@@ -241,7 +287,7 @@ export function WorkflowStudio() {
241
287
  finally { setAction(""); }
242
288
  };
243
289
  const publish = async (): Promise<void> => {
244
- if (!workflowId || !workflow?.draft || !canPublish) return;
290
+ if (!workflowId || !workflow?.draft || !canPublish || unsaved) return;
245
291
  setAction("publish"); setError("");
246
292
  try {
247
293
  const validation = await fetchValidation();
@@ -257,8 +303,8 @@ export function WorkflowStudio() {
257
303
  method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: workflow.draft.revision, authority_mode: authorityMode, test_evidence: { test_run_id: tested.result.testRunId } }),
258
304
  });
259
305
  setWorkflow((current) => current ? { ...current, activeVersionId: result.versionId, activeVersion: { id: result.versionId, version: result.version, authorityMode, publishedAt: new Date().toISOString(), bundleDigest: result.bundleDigest } } : current);
260
- setConsoleState({ kind: "message", heading: `Published version ${result.version}`, detail: `Bundle ${result.bundleDigest}. Pin this version to a queue when you are ready to use it.` });
261
- setPinOpen(true); announceWorkflowChanged();
306
+ setConsoleState({ kind: "message", heading: `Published version ${result.version}`, detail: graph?.playbook ? "The tested version is preserved. Choose a queue below to activate it for new cases." : `Bundle ${result.bundleDigest}. Pin this version to a queue when you are ready to use it.` });
307
+ setLifecycleEpoch(value => value + 1); if (graph?.playbook) { setPinOpen(false); setStudioView("versions"); } else setPinOpen(true); announceWorkflowChanged();
262
308
  } catch (cause) { setError(errorMessage(cause, "The workflow could not be published.")); }
263
309
  finally { setAction(""); }
264
310
  };
@@ -278,7 +324,7 @@ export function WorkflowStudio() {
278
324
  if (!graph) return;
279
325
  const next = structuredClone(graph);
280
326
  next.nodes = next.nodes.map((candidate) => candidate.id === node.id ? node : candidate);
281
- await saveGraph(next);
327
+ if (!await saveGraph(next)) throw new Error("The step could not be saved. Your edits are still here. Check the message below before retrying.");
282
328
  };
283
329
  const removeNode = async (node: WorkflowNode): Promise<void> => {
284
330
  if (!graph || !window.confirm(`Remove “${node.name}” and reconnect its surrounding steps?`)) return;
@@ -286,7 +332,7 @@ export function WorkflowStudio() {
286
332
  if (await saveGraph(next)) setSelectedNodeId(next.entry_node_id || next.nodes[0]?.id || null);
287
333
  };
288
334
  const addNode = async (kind: WorkflowNode["kind"], name: string): Promise<void> => {
289
- if (!graph) return;
335
+ if (!graph || graphReadOnly) return;
290
336
  const inserted = insertWorkflowNode(graph, kind, name, queues[0]?.id);
291
337
  if (await saveGraph(inserted.graph)) setSelectedNodeId(inserted.nodeId);
292
338
  };
@@ -300,32 +346,55 @@ export function WorkflowStudio() {
300
346
  } catch (cause) { setError(errorMessage(cause, "The graph JSON is invalid.")); }
301
347
  };
302
348
 
349
+ const compactChecks = !pinOpen && (showCheckDetails === false || (showCheckDetails === null && (unsaved || (consoleState.kind === "validation" && consoleState.validation.valid && !consoleState.validation.warnings?.length))));
350
+ useEffect(() => setShowCheckDetails(null), [consoleState]);
351
+
303
352
  const subtitle = workflow?.draft
304
353
  ? `${workflow.activeVersion ? `Published v${workflow.activeVersion.version} · ` : ""}Draft revision ${workflow.draft.revision}`
305
354
  : workflow?.activeVersion ? `Published v${workflow.activeVersion.version} · read-only contract` : "Loading workflow…";
306
355
  return (
307
- <dialog ref={dialogRef} className="studio-dialog workflow-studio-dialog" aria-labelledby="workflow-studio-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="workflow-studio">
356
+ <dialog ref={dialogRef} className={`studio-dialog workflow-studio-dialog${graph?.playbook ? " has-playbook" : ""}`} aria-labelledby="workflow-studio-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="workflow-studio">
308
357
  <div className="studio-toolbar">
309
358
  <div><button ref={closeRef} className="icon-button" type="button" aria-label="Close workflow studio" disabled={busy} onClick={close}>←</button><div><h2 id="workflow-studio-title">{workflow?.name || (loading ? "Loading workflow…" : "Workflow studio")}</h2><small>{subtitle}</small></div></div>
310
359
  <div className="studio-actions">
311
- <label className="studio-authority">Authority<select value={authorityMode} disabled={busy || readOnly} onChange={(event) => setAuthorityMode(event.currentTarget.value as WorkflowAuthorityMode)}><option value="off">Off</option><option value="shadow">Shadow</option><option value="assist">Assist</option><option value="bounded_auto">Bounded auto</option></select></label>
312
- <span className="save-state">{saving ? "Saving…" : workflow?.draft ? "All changes saved" : "Read-only"}</span>
313
- <button className="secondary" type="button" disabled={busy || readOnly || !workflow} onClick={() => void validate()}>{action === "validate" ? "Checking…" : "Check"}</button>
314
- <button className="secondary" type="button" disabled={busy || readOnly || !workflow} onClick={() => void test()}>{action === "test" ? "Testing…" : "Test"}</button>
315
- {canPublish ? <button className="primary" type="button" disabled={busy || readOnly || !workflow} onClick={() => void publish()}>{action === "publish" ? "Publishing…" : "Publish"}</button> : null}
316
- {canPin ? <button className="secondary" type="button" disabled={busy || !workflow?.activeVersionId} onClick={() => setPinOpen((value) => !value)}>Pin to queue</button> : null}
360
+ <label className="studio-authority">{graph?.playbook ? "Rollout" : "Authority"}<select value={authorityMode} disabled={busy || readOnly || Boolean(graph?.playbook)} onChange={(event) => setAuthorityMode(event.currentTarget.value as WorkflowAuthorityMode)}><option value="off">Off</option><option value="shadow">Observe</option><option value="assist">Assist</option><option value="bounded_auto">Automate selected decisions</option></select></label>
361
+ <span className="save-state">{saving ? "Saving…" : nodeDirty ? "Unsaved step changes" : graphJsonDirty ? "Unsaved graph changes" : exampleDirty ? "Unsaved example changes" : guideDirty ? "Unsaved guide changes" : workflow?.draft ? "All changes saved" : "Read-only"}</span>
362
+ <button className="secondary" type="button" disabled={busy || readOnly || !workflow || unsaved} onClick={() => void validate()}>{action === "validate" ? "Checking…" : "Check"}</button>
363
+ <button className="secondary" type="button" disabled={busy || readOnly || !workflow || unsaved} onClick={() => void test()}>{action === "test" ? "Testing…" : graph?.playbook ? "Run examples" : "Run test"}</button>
364
+ {canPublish ? <button className="primary" type="button" disabled={busy || readOnly || !workflow || unsaved} onClick={() => void publish()}>{action === "publish" ? "Publishing…" : "Publish"}</button> : null}
365
+ {canPin ? <button className="secondary" type="button" disabled={busy || !workflow?.activeVersionId} onClick={() => graph?.playbook ? setStudioView("versions") : setPinOpen((value) => !value)}>{graph?.playbook ? "Activation" : "Pin to queue"}</button> : null}
317
366
  </div>
318
367
  </div>
319
368
  {loading ? <div className="workflow-studio-loading"><div className="skeleton-block" role="status" aria-label="Loading workflow studio" /></div> : graph && workflow ? <>
369
+ <div className="playbook-view-switch" aria-label="Playbook views">{([...(graph.playbook ? ["guide" as const] : []), "workflow", "test", ...(shell.session?.permissions.readRuns ? ["runs" as const] : []), "versions"] as const).map(view => <button type="button" key={view} aria-pressed={studioView === view} disabled={busy || (unsaved && studioView !== view)} onClick={() => setStudioView(view)}>{readable(view)}</button>)}<span>One plan, from draft to outcome</span></div>
370
+ {studioView === "test" ? <PlaybookExamples workflowId={workflowId!} graph={graph} graphDigest={workflow.draft?.graphDigest} epoch={lifecycleEpoch} disabled={busy || guideDirty} readOnly={readOnly} onPending={setLifecyclePending} onDirty={setExampleDirty} onChanged={() => { announceWorkflowChanged(); }} onRun={() => void test()} /> : null}
371
+ {studioView === "runs" ? <PlaybookRuns workflowId={workflowId!} onOpenCase={clear} /> : null}
372
+ {studioView === "versions" ? <PlaybookVersions workflowId={workflowId!} epoch={lifecycleEpoch} queues={queues} defaultQueueId={pinQueueId} canActivate={canPin} disabled={busy} onPending={setLifecyclePending} onActivated={message => { setPinOpen(false); setConsoleState({ kind: "pin", heading: "Activation updated", detail: message }); }} /> : null}
373
+ {studioView === "guide" && guideDraft ? <section className="playbook-guide-panel">
374
+ <nav className="playbook-section-nav" aria-label="Guide sections">{guideSteps.map((label, index) => <button type="button" key={label} aria-current={guideStep === index ? "step" : undefined} onClick={() => setGuideStep(index)}><span>{String(index + 1).padStart(2, "0")}</span>{label}</button>)}</nav>
375
+ <div className="playbook-guide-content">
376
+ {guideDraft.editing_mode === "advanced" ? <div className="playbook-callout"><strong>This playbook has an advanced workflow.</strong><p>The guide is preserved for reference. Open Workflow to inspect or edit the actual steps.</p></div> : null}
377
+ <PlaybookGuideEditor value={guideDraft} onChange={setGuideDraft} step={guideStep} resources={resources} disabled={busy || readOnly || guideDraft.editing_mode === "advanced"} />
378
+ {resourceError ? <p role="alert">{resourceError} <button type="button" className="text-button" onClick={reloadResources}>Retry</button></p> : null}
379
+ {guideDraft.editing_mode === "guided" && !readOnly ? <div className="playbook-guide-save"><span>{guideDirty ? "Save before testing or switching to the workflow." : "Guide and workflow are in sync."}</span><button className="secondary" type="button" disabled={busy || !guideDirty} onClick={() => setGuideDraft(graph.playbook ?? null)}>Discard changes</button><button className="primary" type="button" disabled={busy || !guideDirty} onClick={() => {
380
+ try { validateGuideInputs(guideDraft, resources); const next = compilePlaybook(guideDraft); void saveGraph(next).then(saved => { if (saved) { setAuthorityMode(playbookAuthority(next.playbook)); setPinMode(next.playbook.rollout === "observe" ? "shadow" : "primary"); setPinQueueId(next.playbook.review.queue_id); } }); }
381
+ catch (cause) { setError(errorMessage(cause, "Check the playbook fields before saving.")); }
382
+ }}>Save guide</button></div> : null}
383
+ </div>
384
+ </section> : null}
385
+ <div className="studio-workflow-view" hidden={studioView !== "workflow"}>
386
+ {graph.playbook?.editing_mode === "guided" ? <div className="playbook-callout"><strong>This workflow is generated from the guide.</strong><p>Inspect any step below. Enabling advanced editing preserves this graph and makes the guide read-only.</p><button className="secondary" type="button" disabled={busy || readOnly} onClick={() => { const next = structuredClone(graph); if (next.playbook) next.playbook.editing_mode = "advanced"; void saveGraph(next); }}>Enable advanced editing</button></div> : null}
320
387
  <div className="studio-body">
321
- <aside className="node-palette"><h3>Add a step</h3><p>Choose a step. Use Advanced graph for branches or parallel paths.</p><div className="node-palette-list">{workflowNodeChoices.map((choice) => <button className="palette-button" type="button" disabled={busy || readOnly} onClick={() => void addNode(choice.kind, choice.name)} key={choice.kind}><i className={choice.tone} /><span><strong>{choice.name}</strong><small>{choice.detail}</small></span></button>)}</div><button className="text-button" type="button" disabled={busy} onClick={() => { setAdvanced((value) => !value); setGraphJson(JSON.stringify(graph, null, 2)); }}>{advanced ? "Hide advanced graph" : "Advanced graph JSON"}</button></aside>
322
- <main className="workflow-canvas-shell"><div className="canvas-legend"><span><i />Read-only</span><span><i className="human" />Human</span><span><i className="action" />Action</span></div><WorkflowCanvas graph={graph} selectedNodeId={selectedNodeId} onSelect={setSelectedNodeId} /></main>
323
- <aside className="node-inspector"><NodeInspector node={selectedNode} readOnly={readOnly} busy={busy} onApply={applyNode} onRemove={removeNode} /></aside>
388
+ <aside className="node-palette"><h3>Add a step</h3><p>Choose a step. Use Advanced graph for branches or parallel paths.</p><div className="node-palette-list">{workflowNodeChoices.map((choice) => <button className="palette-button" type="button" disabled={busy || graphReadOnly || unsaved} onClick={() => void addNode(choice.kind, choice.name)} key={choice.kind}><i className={choice.tone} /><span><strong>{choice.name}</strong><small>{choice.detail}</small></span></button>)}</div><button className="text-button" type="button" disabled={busy || nodeDirty} onClick={() => { if (graphJsonDirty && !window.confirm("Discard the unsaved graph JSON?")) return; setAdvanced((value) => !value); setGraphJson(JSON.stringify(graph, null, 2)); }}>{advanced ? "Hide advanced graph" : "Advanced graph JSON"}</button></aside>
389
+ <main className="workflow-canvas-shell"><div className="canvas-legend"><span><i />Read-only</span><span><i className="human" />Human</span><span><i className="action" />Action</span></div><WorkflowCanvas graph={graph} selectedNodeId={selectedNodeId} onSelect={id => { if (id !== selectedNodeId && nodeDirty && !window.confirm("Discard the unsaved step changes?")) return; setSelectedNodeId(id); }} /></main>
390
+ <aside className="node-inspector"><NodeInspector node={selectedNode} readOnly={graphReadOnly} busy={busy || graphJsonDirty} onApply={applyNode} onRemove={removeNode} onDirty={setNodeDirty} /></aside>
391
+ </div>
392
+ {advanced ? <section className="graph-json-editor"><div><h3>Advanced graph JSON</h3><p>Edit branches, parallel groups, and typed mappings.</p></div><textarea aria-label="Workflow graph JSON" spellCheck={false} readOnly={graphReadOnly || busy || nodeDirty} value={graphJson} onChange={(event) => setGraphJson(event.currentTarget.value)} />{!graphReadOnly ? <button className="secondary" type="button" disabled={busy || nodeDirty || !graphJsonDirty} onClick={() => void applyGraphJson()}>Apply JSON</button> : null}</section> : null}
324
393
  </div>
325
- {advanced ? <section className="graph-json-editor"><div><h3>Advanced graph JSON</h3><p>Edit branches, parallel groups, and typed mappings.</p></div><textarea aria-label="Workflow graph JSON" spellCheck={false} readOnly={readOnly || busy} value={graphJson} onChange={(event) => setGraphJson(event.currentTarget.value)} />{!readOnly ? <button className="secondary" type="button" disabled={busy} onClick={() => void applyGraphJson()}>Apply JSON</button> : null}</section> : null}
326
394
  </> : null}
327
- <section className="studio-console" aria-label="Checks and test results"><div className="console-head"><strong>Checks and test results</strong><span>{workflow?.draft?.validationStatus ? readable(workflow.draft.validationStatus) : readOnly ? "Published" : "Not checked"}</span></div><div className="console-output"><WorkflowConsole state={consoleState} />{pinOpen ? <form className="pin-control" onSubmit={(event) => void pin(event)}><label>Queue<select required disabled={busy} value={pinQueueId} onChange={(event) => setPinQueueId(event.currentTarget.value)}><option value="">Select a queue</option>{queues.map((queue) => <option value={queue.id} key={queue.id}>{queue.name}</option>)}</select></label><label>Mode<select disabled={busy} value={pinMode} onChange={(event) => setPinMode(event.currentTarget.value as "primary" | "shadow")}><option value="primary">Primary</option><option value="shadow">Shadow</option></select></label><button className="primary" type="submit" disabled={busy || !pinQueueId}>{action === "pin" ? "Pinning…" : "Pin published version"}</button></form> : null}</div></section>
395
+ <section className={`studio-console${compactChecks ? " compact-checks" : ""}`} hidden={Boolean(conflict || (graph?.playbook && studioView === "test" && !pinOpen && (consoleState.kind === "test" || (consoleState.kind === "validation" && consoleState.validation.valid))))} aria-label="Checks and test results"><div className="console-head"><strong>Checks and test results</strong><button type="button" className="text-button mobile-check-toggle" aria-expanded={!compactChecks} onClick={() => setShowCheckDetails(compactChecks)}>{compactChecks ? "Show details" : "Hide details"}</button><span>{unsaved ? "Unsaved draft" : workflow?.draft?.validationStatus ? readable(workflow.draft.validationStatus) : readOnly ? "Published" : "Not checked"}</span></div><div className="console-output" tabIndex={0} role="region" aria-label="Detailed test results">{unsaved ? <p>Save your edits before checking or publishing this draft. Previous results describe the saved revision.</p> : <WorkflowConsole state={consoleState} />}{pinOpen ? <form className="pin-control" onSubmit={(event) => void pin(event)}><label>Queue<select required disabled={busy} value={pinQueueId} onChange={(event) => setPinQueueId(event.currentTarget.value)}><option value="">Select a queue</option>{queues.map((queue) => <option value={queue.id} key={queue.id}>{queue.name}</option>)}</select></label><label>Mode<select disabled={busy} value={pinMode} onChange={(event) => setPinMode(event.currentTarget.value as "primary" | "shadow")}><option value="primary">Primary</option><option value="shadow">Shadow</option></select></label><button className="primary" type="submit" disabled={busy || !pinQueueId}>{action === "pin" ? "Pinning…" : "Pin published version"}</button></form> : null}</div></section>
328
396
  <p className="error studio-error" role="alert">{error}</p>
397
+ {conflict ? <div className="playbook-callout studio-conflict"><strong>Another saved revision is available.</strong><p>Your local edits are still in the editor. Keep or copy them before replacing them with the saved revision.</p><button type="button" className="secondary" disabled={busy} onClick={() => { if (!unsaved || window.confirm("Discard your local edits and load the latest saved revision?")) setLoadEpoch(value => value + 1); }}>Reload saved revision</button></div> : null}
329
398
  </dialog>
330
399
  );
331
400
  }
@@ -5,6 +5,7 @@ import { errorMessage, requestJson } from "../lib/http";
5
5
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
6
6
  import { openWorkflowCreate, openWorkflowStudio, workflowEvents } from "./events";
7
7
  import type { WorkflowListResponse, WorkflowSummary } from "./types";
8
+ import { openPlaybookCreate } from "./CreatePlaybookDialog";
8
9
 
9
10
  function WorkflowCard({ workflow }: { workflow: WorkflowSummary }) {
10
11
  const version = workflow.activeVersion?.version;
@@ -16,15 +17,15 @@ function WorkflowCard({ workflow }: { workflow: WorkflowSummary }) {
16
17
  <span className={`badge${workflow.status !== "active" ? " urgent" : ""}`}>{version ? `v${version}` : "Draft"}</span>
17
18
  </div>
18
19
  <h3>{workflow.name}</h3>
19
- <p>{workflow.description || "No description yet."}</p>
20
+ <p>{workflow.playbook?.summary || workflow.description || "No description yet."}</p>
20
21
  <div className="object-card-meta">
21
- <div><span>Purpose</span><strong>{readable(workflow.purpose)}</strong></div>
22
+ <div><span>Design</span><strong>{workflow.playbook?.editing_mode === "guided" ? "Guided playbook" : "Advanced workflow"}</strong></div>
22
23
  <div><span>Authority</span><strong>{readable(authority || "Not published")}</strong></div>
23
24
  <div><span>Queue pins</span><strong>{workflow.activePinCount || 0}</strong></div>
24
25
  </div>
25
26
  <div className="object-card-footer">
26
27
  <small>{workflow.draft ? `Draft revision ${workflow.draft.revision}` : "Published contract"}</small>
27
- <button className="secondary" type="button" onClick={() => openWorkflowStudio(workflow.id)}>Open studio</button>
28
+ <button className="secondary" type="button" onClick={() => openWorkflowStudio(workflow.id)}>{workflow.playbook ? "Open playbook" : "Open studio"}</button>
28
29
  </div>
29
30
  </article>
30
31
  );
@@ -64,16 +65,17 @@ export function WorkflowWorkspace() {
64
65
 
65
66
  return (
66
67
  <div className="page-stack" data-react-slice="workflow-workspace">
67
- <PageHeader titleId="workflows-title" title="Workflows" description="Build and publish the path each report follows.">
68
+ <PageHeader titleId="workflows-title" title="Playbooks" description="Teach Safest how your team resolves a situation, from evidence to a confirmed outcome.">
68
69
  <div className="surface-buttons">
69
70
  <button className="secondary" type="button" disabled={loading || refreshing || !canManage} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button>
70
- <button className="primary" type="button" disabled={!canManage} onClick={openWorkflowCreate}>New workflow</button>
71
+ <button className="secondary" type="button" disabled={!canManage} onClick={openWorkflowCreate}>New workflow</button>
72
+ <button className="primary" type="button" disabled={!canManage} onClick={openPlaybookCreate}>New playbook</button>
71
73
  </div>
72
74
  </PageHeader>
73
- <div className="safety-boundary"><strong>Read-only steps inspect data. Actions change it.</strong><span>Publishing checks every failure path.</span></div>
75
+ <div className="playbook-library-intro"><div><span className="eyebrow">Your team's judgement, made repeatable</span><h2>A clear plan for every kind of case.</h2><p>Start with a situation and your policy. Choose the evidence Safest can gather, who reviews the decision, and what your product should do next.</p></div><div className="playbook-library-steps"><span>01 &nbsp; Describe the situation</span><span>02 &nbsp; Set the boundaries</span><span>03 &nbsp; Test with examples</span></div></div>
74
76
  <div className="object-grid workflow-list" aria-busy={loading || refreshing}>
75
77
  {loading ? <><div className="skeleton-card" /><div className="skeleton-card" /></> : workflows.length ? workflows.map((workflow) => <WorkflowCard workflow={workflow} key={workflow.id} />) : error ? null : (
76
- <div className="empty-product"><span aria-hidden="true">⌘</span><h2>No workflows yet</h2><p>Start with human review and add automation when you need it.</p>{canManage ? <button className="primary" type="button" onClick={openWorkflowCreate}>New workflow</button> : null}</div>
78
+ <div className="empty-product"><span aria-hidden="true">⌘</span><h2>Your first playbook starts here</h2><p>Choose a starter and adapt it to the way your team works.</p>{canManage ? <button className="primary" type="button" onClick={openPlaybookCreate}>New playbook</button> : null}</div>
77
79
  )}
78
80
  </div>
79
81
  <p className="error surface-error" role="alert">{error}</p>
@@ -39,7 +39,7 @@ export function workflowTemplate(template: WorkflowTemplate, purpose: WorkflowPu
39
39
  node("related-check", "enrichment", "Related reports", 270, 480, { component_version_id: "select-a-published-component-version" }, { target: "report.target.reference" }),
40
40
  node("join", "join", "Evidence ready", 520, 260, { required_ports: ["url", "account"], optional_ports: ["related"] }),
41
41
  node("review", "human_task", "Human review", 760, 90, { queue_id: "select-a-queue", response_schema: { type: "object" } }),
42
- node("decision", "decision", "Record decision", 760, 260, { policy_version_id: "select-a-policy-version" }, { decision_code: "reviewed", policy_code: "manual-review", rationale: "Recorded after review.", evidence_references: [], finding_ids: [] }),
42
+ node("decision", "decision", "Record decision", 760, 260, { policy_version_id: "select-a-policy-version" }, { decision_code: "no_violation_found", policy_code: "GENERAL-1", rationale: "Recorded after review.", evidence_references: [], finding_ids: [] }),
43
43
  node("end", "end", "Resolved", 1000, 260, { terminal_code: "resolved" }),
44
44
  ],
45
45
  edges: [
@@ -52,8 +52,8 @@ export function workflowTemplate(template: WorkflowTemplate, purpose: WorkflowPu
52
52
  ...base,
53
53
  nodes: [
54
54
  node("start", "start", "Report accepted", 40, 220),
55
- node("review", "human_task", "Human review", 280, 220, { queue_id: "general-review", response_schema: { type: "object" } }),
56
- node("decision", "decision", "Record decision", 520, 220, { policy_version_id: "policy-v1" }, { decision_code: "reviewed", policy_code: "manual-review", rationale: "Recorded after the required human review step.", evidence_references: [], finding_ids: [] }),
55
+ node("review", "human_task", "Human review", 280, 220, { queue_id: "standard-human", response_schema: { type: "object" } }),
56
+ node("decision", "decision", "Record decision", 520, 220, { policy_version_id: "general-policy-v1" }, { decision_code: "no_violation_found", policy_code: "GENERAL-1", rationale: "Recorded after the required human review step.", evidence_references: [], finding_ids: [] }),
57
57
  node("end", "end", "Resolved", 760, 220, { terminal_code: "resolved" }),
58
58
  ],
59
59
  edges: [edge("e1", "start", "success", "review"), edge("e2", "review", "completed", "decision"), edge("e3", "review", "timeout", "decision"), edge("e4", "decision", "recorded", "end"), edge("e5", "decision", "error", "end")],
@@ -1,3 +1,5 @@
1
+ import type { PlaybookGuide } from "../../shared/playbooks";
2
+
1
3
  export type JsonPrimitive = string | number | boolean | null;
2
4
  export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
3
5
  export interface JsonObject { [key: string]: JsonValue }
@@ -43,6 +45,7 @@ export interface WorkflowEdge {
43
45
  }
44
46
 
45
47
  export interface WorkflowGraph {
48
+ playbook?: PlaybookGuide;
46
49
  schema_version: "1";
47
50
  purpose: WorkflowPurpose;
48
51
  entry_node_id: string;
@@ -93,6 +96,9 @@ export interface WorkflowVersionSummary {
93
96
 
94
97
  export interface WorkflowFixture {
95
98
  id: string;
99
+ revision: number;
100
+ componentResults?: JsonObject;
101
+ humanResponses?: JsonObject;
96
102
  name: string;
97
103
  input: JsonObject;
98
104
  expected: JsonObject;
@@ -101,6 +107,7 @@ export interface WorkflowFixture {
101
107
  }
102
108
 
103
109
  export interface WorkflowSummary {
110
+ playbook?: PlaybookGuide | null;
104
111
  id: string;
105
112
  key: string;
106
113
  name: string;
@@ -157,6 +164,7 @@ export interface WorkflowTestStep {
157
164
  nodeId?: string;
158
165
  node_id?: string;
159
166
  state?: string;
167
+ port?: string;
160
168
  }
161
169
 
162
170
  export interface WorkflowTestResponse {
@@ -167,6 +175,9 @@ export interface WorkflowTestResponse {
167
175
  terminalCode?: string;
168
176
  terminal_code?: string;
169
177
  steps?: WorkflowTestStep[];
178
+ expected?: JsonObject;
179
+ expectedMatched?: boolean;
180
+ error?: { code: string; nodeId?: string } | null;
170
181
  };
171
182
  }
172
183
 
@@ -0,0 +1,36 @@
1
+ -- Additive: earlier published versions remain readable and runnable.
2
+ ALTER TABLE workflow_fixtures ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;
3
+ ALTER TABLE workflow_test_runs ADD COLUMN fixture_revision INTEGER;
4
+ ALTER TABLE workflow_versions ADD COLUMN source_draft_revision INTEGER;
5
+
6
+ CREATE INDEX workflow_test_runs_fixture_revision_idx
7
+ ON workflow_test_runs(workflow_id, fixture_id, fixture_revision, graph_digest, finished_at DESC);
8
+
9
+ -- Runs retain exactly the fixture that was read before simulation, even if an
10
+ -- editor saves while the simulation is in flight. Old tests require a rerun.
11
+ CREATE TRIGGER workflow_publish_exact_draft
12
+ BEFORE INSERT ON workflow_versions
13
+ WHEN NEW.source_draft_revision IS NOT NULL
14
+ BEGIN
15
+ SELECT RAISE(ABORT, 'workflow_publication_conflict') WHERE NOT EXISTS (
16
+ SELECT 1 FROM workflow_drafts d JOIN workflow_definitions w ON w.id = d.workflow_id
17
+ WHERE d.workflow_id = NEW.workflow_id AND d.revision = NEW.source_draft_revision
18
+ AND d.graph_digest = NEW.graph_digest AND w.status = 'active'
19
+ );
20
+ SELECT RAISE(ABORT, 'playbook_publication_evidence_changed')
21
+ WHERE json_type(NEW.graph_json, '$.playbook') = 'object' AND (
22
+ NOT EXISTS (SELECT 1 FROM workflow_fixtures f WHERE f.workflow_id = NEW.workflow_id)
23
+ OR EXISTS (
24
+ SELECT 1 FROM workflow_fixtures f WHERE f.workflow_id = NEW.workflow_id AND NOT EXISTS (
25
+ SELECT 1 FROM workflow_test_runs t
26
+ WHERE t.id = (
27
+ SELECT latest.id FROM workflow_test_runs latest
28
+ WHERE latest.workflow_id = NEW.workflow_id AND latest.fixture_id = f.id
29
+ AND latest.fixture_revision = f.revision AND latest.graph_digest = NEW.graph_digest
30
+ ORDER BY latest.rowid DESC LIMIT 1
31
+ ) AND t.status = 'passed' AND t.mode = 'simulation'
32
+ AND t.id IN (SELECT value FROM json_each(NEW.test_evidence_json, '$.example_test_run_ids'))
33
+ )
34
+ )
35
+ );
36
+ END;
@@ -0,0 +1,24 @@
1
+ -- Keep transport attempts separate from customer acknowledgement and completion.
2
+ -- Attempt numbers remain monotonic across an operator-requested retry.
3
+ ALTER TABLE action_proposals ADD COLUMN accepted_at TEXT;
4
+ ALTER TABLE action_proposals ADD COLUMN confirmed_at TEXT;
5
+ ALTER TABLE action_proposals ADD COLUMN confirmation_deadline_at TEXT;
6
+ ALTER TABLE action_proposals ADD COLUMN last_receipt_json TEXT;
7
+ ALTER TABLE action_proposals ADD COLUMN delivery_cycle INTEGER NOT NULL DEFAULT 1 CHECK (delivery_cycle >= 1);
8
+ ALTER TABLE action_proposals ADD COLUMN cycle_start_attempt INTEGER NOT NULL DEFAULT 0 CHECK (cycle_start_attempt >= 0);
9
+
10
+ CREATE INDEX action_confirmation_due ON action_proposals(confirmation_deadline_at, id)
11
+ WHERE accepted_at IS NOT NULL AND delivery_terminal = 0;
12
+
13
+ -- Human approvals must still match a live, unexpired immutable proposal in the
14
+ -- transaction that records them, including the direct approval API.
15
+ CREATE TRIGGER action_approval_requires_current_proposal
16
+ BEFORE INSERT ON action_approvals
17
+ WHEN NOT EXISTS (
18
+ SELECT 1 FROM action_proposals p WHERE p.id = NEW.proposal_id
19
+ AND p.state = 'approval_required' AND p.payload_digest = NEW.payload_digest
20
+ AND json_extract(p.approval_policy_json, '$.expires_at') > strftime('%Y-%m-%dT%H:%M:%fZ','now')
21
+ )
22
+ BEGIN
23
+ SELECT RAISE(ABORT, 'action_approval_state_changed');
24
+ END;
@@ -0,0 +1,22 @@
1
+ -- Preserve attempt history when an operator retries a failed participant notice.
2
+ ALTER TABLE notification_outbox ADD COLUMN attempt_limit INTEGER NOT NULL DEFAULT 8 CHECK (attempt_limit >= 8);
3
+ ALTER TABLE email_deliveries ADD COLUMN attempt_limit INTEGER NOT NULL DEFAULT 8 CHECK (attempt_limit >= 8);
4
+
5
+ CREATE TABLE email_delivery_attempts (
6
+ id TEXT PRIMARY KEY,
7
+ delivery_id TEXT NOT NULL REFERENCES email_deliveries(id) ON DELETE CASCADE,
8
+ attempt_number INTEGER NOT NULL CHECK (attempt_number > 0),
9
+ result TEXT NOT NULL CHECK (result IN ('delivered', 'failed', 'suppressed')),
10
+ provider_message_id TEXT,
11
+ error_code TEXT,
12
+ started_at TEXT NOT NULL,
13
+ finished_at TEXT NOT NULL,
14
+ UNIQUE (delivery_id, attempt_number)
15
+ );
16
+
17
+ -- Owners were already stored from authenticated server/contextual intake.
18
+ -- Public submissions cannot assert ownership and are deliberately excluded.
19
+ INSERT OR IGNORE INTO report_participants (id, report_id, audience, reference, contact_mode, created_at)
20
+ SELECT 'affected-owner:' || r.id, r.id, 'affected_user', t.owner_reference, 'customer_notification', r.received_at
21
+ FROM reports r JOIN report_targets t ON t.report_id = r.id
22
+ WHERE r.source IN ('server_api','widget') AND t.owner_reference IS NOT NULL AND t.sensitive_redacted_at IS NULL;
@@ -0,0 +1,17 @@
1
+ -- A verdict and completion of its participant/customer follow-up are separate.
2
+ -- Existing verdicts keep their historical completion semantics.
3
+ ALTER TABLE appeal_decisions ADD COLUMN request_digest TEXT;
4
+ ALTER TABLE appeal_decisions ADD COLUMN completion_state TEXT NOT NULL DEFAULT 'legacy' CHECK (completion_state IN ('legacy','waiting','completed'));
5
+ ALTER TABLE appeal_decisions ADD COLUMN correction_required INTEGER NOT NULL DEFAULT 0 CHECK (correction_required IN (0,1));
6
+ ALTER TABLE appeal_decisions ADD COLUMN correction_reference TEXT;
7
+ ALTER TABLE appeal_decisions ADD COLUMN correction_note TEXT;
8
+ ALTER TABLE appeal_decisions ADD COLUMN correction_confirmed_by TEXT;
9
+ ALTER TABLE appeal_decisions ADD COLUMN correction_confirmed_at TEXT;
10
+
11
+ CREATE INDEX appeal_completion_waiting ON appeal_decisions(completion_state, created_at, id);
12
+
13
+ -- Restore the owning queue for legacy appeal closures where it is recoverable.
14
+ UPDATE reports SET queue_id = COALESCE(
15
+ (SELECT wr.queue_id FROM workflow_runs wr WHERE wr.report_id = reports.id AND wr.run_kind = 'primary' ORDER BY wr.created_at DESC LIMIT 1),
16
+ (SELECT qv.queue_id FROM queue_versions qv WHERE qv.id = reports.queue_version_id)
17
+ ) WHERE state = 'closed' AND queue_id IS NULL AND EXISTS (SELECT 1 FROM appeals WHERE report_id = reports.id);
@@ -0,0 +1,23 @@
1
+ -- Expired legacy claims were already unassigned in the previous product.
2
+ -- From this migration onward a row represents lasting responsibility, while
3
+ -- expires_at controls only temporary editing access.
4
+ DELETE FROM review_claims WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now');
5
+
6
+ CREATE TABLE report_handoffs (
7
+ id TEXT PRIMARY KEY,
8
+ report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
9
+ from_reviewer_id TEXT NOT NULL,
10
+ to_reviewer_id TEXT,
11
+ actor_id TEXT NOT NULL,
12
+ note TEXT NOT NULL CHECK(length(note) BETWEEN 1 AND 4000),
13
+ created_at TEXT NOT NULL,
14
+ redacted_at TEXT
15
+ );
16
+ CREATE INDEX report_handoffs_case ON report_handoffs(report_id, created_at);
17
+
18
+ -- A failed authorization check aborts the entire D1 mutation batch, including
19
+ -- participant notices and delivery outboxes. Successful batches remove the row.
20
+ CREATE TABLE report_edit_guards (
21
+ id TEXT PRIMARY KEY,
22
+ authorized INTEGER NOT NULL CONSTRAINT active_report_edit_access CHECK(authorized = 1)
23
+ );
@@ -0,0 +1,21 @@
1
+ -- Evidence remains derived from the one linked case, never stored as checkboxes.
2
+ CREATE TABLE integration_verifications (
3
+ id TEXT PRIMARY KEY,
4
+ installation_id TEXT NOT NULL REFERENCES installations(id),
5
+ integration_id TEXT NOT NULL REFERENCES application_integrations(id),
6
+ form_version_id TEXT NOT NULL REFERENCES report_form_versions(id),
7
+ queue_id TEXT NOT NULL REFERENCES queue_definitions(id),
8
+ workflow_version_id TEXT NOT NULL REFERENCES workflow_versions(id),
9
+ pin_id TEXT NOT NULL REFERENCES queue_workflow_pins(id),
10
+ decision_code TEXT NOT NULL,
11
+ configuration_digest TEXT NOT NULL,
12
+ configuration_json TEXT NOT NULL CHECK (json_valid(configuration_json)),
13
+ report_id TEXT UNIQUE REFERENCES reports(id) ON DELETE CASCADE,
14
+ workflow_run_id TEXT UNIQUE REFERENCES workflow_runs(id) ON DELETE CASCADE,
15
+ started_by TEXT NOT NULL,
16
+ started_at TEXT NOT NULL,
17
+ linked_at TEXT,
18
+ invalidated_at TEXT,
19
+ CHECK ((report_id IS NULL) = (workflow_run_id IS NULL))
20
+ );
21
+ CREATE INDEX integration_verifications_recent ON integration_verifications(started_at DESC, id);