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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/template/README.md +1 -1
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +12 -6
- package/template/console/command/controller.ts +13 -9
- package/template/console/command/presentation.ts +1 -6
- package/template/console/command/store.ts +1 -0
- package/template/console/command/types.ts +1 -0
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +34 -4
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +109 -67
- package/template/console/reports/ReportWorkspace.tsx +11 -12
- package/template/console/reports/controller.ts +96 -49
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +41 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +22 -8
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +25 -17
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +18701 -11800
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +329 -10
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +3 -2
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/database-readiness.ts +8 -3
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +178 -18
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-delivery.ts +136 -129
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-operations.ts +16 -63
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +181 -274
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +18 -7
- package/template/src/workflow-effects.ts +95 -18
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +9 -4
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -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)
|
|
154
|
-
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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=
|
|
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">
|
|
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…" : "
|
|
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 ||
|
|
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={
|
|
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=
|
|
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>
|
|
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="
|
|
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="
|
|
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="
|
|
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 Describe the situation</span><span>02 Set the boundaries</span><span>03 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>
|
|
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: "
|
|
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: "
|
|
56
|
-
node("decision", "decision", "Record decision", 520, 220, { policy_version_id: "policy-v1" }, { decision_code: "
|
|
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);
|