create-safest-tools 0.2.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 (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,331 @@
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
2
+ import { readable } from "../lib/format";
3
+ import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
5
+ import { announceWorkflowChanged, workflowEvents } from "./events";
6
+ import { insertWorkflowNode, removeWorkflowNode, workflowNodeChoices } from "./graph";
7
+ import { WorkflowCanvas } from "./WorkflowCanvas";
8
+ import type {
9
+ JsonObject,
10
+ QueueListItem,
11
+ QueueListResponse,
12
+ StudioConsoleState,
13
+ WorkflowAuthorityMode,
14
+ WorkflowDetail,
15
+ WorkflowGraph,
16
+ WorkflowNode,
17
+ WorkflowPinResponse,
18
+ WorkflowPublishResponse,
19
+ WorkflowResponse,
20
+ WorkflowSaveResponse,
21
+ WorkflowTestResponse,
22
+ WorkflowValidationResponse,
23
+ WorkflowVersionResponse,
24
+ } from "./types";
25
+
26
+ function jsonObject(value: string, label: string): JsonObject {
27
+ const parsed: unknown = JSON.parse(value || "{}");
28
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} must be a JSON object.`);
29
+ return parsed as JsonObject;
30
+ }
31
+
32
+ function errorResponse<T>(cause: unknown, status: number): T | null {
33
+ if (!(cause instanceof ApiRequestError) || cause.status !== status || !cause.body || typeof cause.body !== "object") return null;
34
+ return cause.body as T;
35
+ }
36
+
37
+ function validationState(workflow: WorkflowDetail, heading: string): StudioConsoleState {
38
+ return workflow.draft?.validation
39
+ ? { kind: "validation", heading, validation: workflow.draft.validation }
40
+ : { kind: "message", heading: "Published workflow", detail: "This immutable version can be inspected or pinned to a queue." };
41
+ }
42
+
43
+ function WorkflowConsole({ state }: { state: StudioConsoleState }) {
44
+ if (state.kind === "validation") {
45
+ const issues = [...(state.validation.errors || []).map((issue) => ({ ...issue, tone: "Error" })), ...(state.validation.warnings || []).map((issue) => ({ ...issue, tone: "Warning" }))];
46
+ return <>
47
+ <p>{state.heading}: {state.validation.valid ? "ready" : "needs changes"}. {state.validation.errors?.length || 0} errors and {state.validation.warnings?.length || 0} warnings.</p>
48
+ {issues.map((issue, index) => <div className="console-issue" key={`${issue.code}-${issue.nodeId || issue.edgeId || issue.path || index}`}><strong>{issue.tone}</strong><span>{issue.message}</span><code>{issue.nodeId || issue.edgeId || issue.path || issue.code}</code></div>)}
49
+ {state.validation.valid && !state.validation.warnings?.length ? <p>No blocking problems. Estimated maximum: {state.validation.estimatedMaximumSteps ?? "—"} steps.</p> : null}
50
+ </>;
51
+ }
52
+ if (state.kind === "test") {
53
+ const terminal = state.result.result?.terminalCode || state.result.result?.terminal_code || "—";
54
+ return <><p>Fixture {state.fixtureName}: {readable(state.result.status)}. Terminal {terminal}.</p>{(state.result.result?.steps || []).map((step, index) => <div className="console-issue" key={`${step.nodeId || step.node_id || "step"}-${index}`}><strong>Step</strong><span>{step.nodeId || step.node_id || "Step"}</span><code>{readable(step.state || "completed")}</code></div>)}</>;
55
+ }
56
+ return <><p><strong>{state.heading}</strong></p><p>{state.detail}</p></>;
57
+ }
58
+
59
+ function NodeInspector({ node, readOnly, busy, onApply, onRemove }: {
60
+ node: WorkflowNode | null;
61
+ readOnly: boolean;
62
+ busy: boolean;
63
+ onApply: (node: WorkflowNode) => Promise<void>;
64
+ onRemove: (node: WorkflowNode) => Promise<void>;
65
+ }) {
66
+ const [name, setName] = useState("");
67
+ const [description, setDescription] = useState("");
68
+ const [errorPort, setErrorPort] = useState("");
69
+ const [mapping, setMapping] = useState("{}");
70
+ const [config, setConfig] = useState("{}");
71
+ const [error, setError] = useState("");
72
+
73
+ useEffect(() => {
74
+ setName(node?.name || ""); setDescription(node?.description || ""); setErrorPort(node?.error_port || "");
75
+ setMapping(JSON.stringify(node?.input_mapping || {}, null, 2)); setConfig(JSON.stringify(node?.config || {}, null, 2)); setError("");
76
+ }, [node?.id, node?.name, node?.description, node?.error_port, node?.input_mapping, node?.config]);
77
+
78
+ if (!node) return <div className="node-inspector-empty"><h3>Select a step</h3><p>Review its inputs, retries, and failure path.</p></div>;
79
+ const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
80
+ event.preventDefault(); setError("");
81
+ try {
82
+ await onApply({
83
+ ...node,
84
+ name: name.trim(),
85
+ ...(description.trim() ? { description: description.trim() } : { description: undefined }),
86
+ ...(errorPort.trim() ? { error_port: errorPort.trim() } : { error_port: undefined }),
87
+ input_mapping: jsonObject(mapping, "Input mapping"),
88
+ config: jsonObject(config, "Configuration"),
89
+ });
90
+ } catch (cause) { setError(errorMessage(cause, "The step configuration is not valid JSON.")); }
91
+ };
92
+ const removable = node.kind !== "start" && node.kind !== "end";
93
+ return <form onSubmit={(event) => void submit(event)}>
94
+ <p className="context-label">{readable(node.kind)}</p><h3>{node.name}</h3>
95
+ <label>Name<input maxLength={160} required disabled={readOnly || busy} value={name} onChange={(event) => setName(event.currentTarget.value)} /></label>
96
+ <label>Description<textarea maxLength={2000} disabled={readOnly || busy} value={description} onChange={(event) => setDescription(event.currentTarget.value)} /></label>
97
+ <label>Failure port<input maxLength={100} disabled={readOnly || busy} value={errorPort} onChange={(event) => setErrorPort(event.currentTarget.value)} placeholder="operations" /></label>
98
+ <label>Input mapping <small>Only mapped data leaves the workflow context.</small><textarea className="code-input" spellCheck={false} disabled={readOnly || busy} value={mapping} onChange={(event) => setMapping(event.currentTarget.value)} /></label>
99
+ <label>Configuration <small>JSON settings for this step type.</small><textarea className="code-input" spellCheck={false} disabled={readOnly || busy} value={config} onChange={(event) => setConfig(event.currentTarget.value)} /></label>
100
+ <p className="error" role="alert">{error}</p>
101
+ {!readOnly ? <div className="inspector-actions">{removable ? <button className="danger-button" type="button" disabled={busy} onClick={() => void onRemove(node)}>Remove step</button> : <span /> }<button className="primary" type="submit" disabled={busy}>{busy ? "Saving…" : "Apply"}</button></div> : null}
102
+ </form>;
103
+ }
104
+
105
+ export function WorkflowStudio() {
106
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
107
+ const dialogRef = useRef<HTMLDialogElement>(null);
108
+ const closeRef = useRef<HTMLButtonElement>(null);
109
+ const [workflowId, setWorkflowId] = useState<string | null>(null);
110
+ const [openWarning, setOpenWarning] = useState("");
111
+ const [workflow, setWorkflow] = useState<WorkflowDetail | null>(null);
112
+ const [graph, setGraph] = useState<WorkflowGraph | null>(null);
113
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
114
+ const [queues, setQueues] = useState<QueueListItem[]>([]);
115
+ const [authorityMode, setAuthorityMode] = useState<WorkflowAuthorityMode>("assist");
116
+ const [loading, setLoading] = useState(false);
117
+ const [saving, setSaving] = useState(false);
118
+ const [action, setAction] = useState<"" | "validate" | "test" | "publish" | "pin">("");
119
+ const [error, setError] = useState("");
120
+ const [advanced, setAdvanced] = useState(false);
121
+ const [graphJson, setGraphJson] = useState("");
122
+ const [consoleState, setConsoleState] = useState<StudioConsoleState>({ kind: "message", heading: "Workflow studio", detail: "Open a draft to validate and test it." });
123
+ const [pinOpen, setPinOpen] = useState(false);
124
+ const [pinQueueId, setPinQueueId] = useState("");
125
+ const [pinMode, setPinMode] = useState<"primary" | "shadow">("primary");
126
+ const canPublish = shell.session?.permissions.publishWorkflows === true;
127
+ const canPin = shell.session?.permissions.manageWorkflowPins === true;
128
+ const readOnly = Boolean(workflow && !workflow.draft);
129
+ const busy = loading || saving || Boolean(action);
130
+ const selectedNode = useMemo(() => graph?.nodes.find((node) => node.id === selectedNodeId) ?? null, [graph, selectedNodeId]);
131
+
132
+ 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);
134
+ };
135
+ const close = (): void => { if (!busy) clear(); };
136
+
137
+ useEffect(() => {
138
+ const openStudio = (event: Event): void => {
139
+ if (!(event instanceof CustomEvent) || typeof event.detail?.workflowId !== "string") return;
140
+ setOpenWarning(typeof event.detail.warning === "string" ? event.detail.warning : "");
141
+ setWorkflowId(event.detail.workflowId);
142
+ };
143
+ window.addEventListener(workflowEvents.openStudio, openStudio);
144
+ return () => window.removeEventListener(workflowEvents.openStudio, openStudio);
145
+ }, []);
146
+ useEffect(() => {
147
+ const dialog = dialogRef.current;
148
+ if (!dialog) return;
149
+ if (workflowId && !dialog.open) { dialog.showModal(); requestAnimationFrame(() => closeRef.current?.focus()); }
150
+ if (!workflowId && dialog.open) dialog.close();
151
+ }, [workflowId]);
152
+ useEffect(() => {
153
+ if (workflowId && shell.currentView !== "workflows" && !busy) clear();
154
+ }, [shell.currentView, workflowId, busy]);
155
+ useEffect(() => {
156
+ if (!workflowId) return;
157
+ let active = true;
158
+ const load = async (): Promise<void> => {
159
+ setLoading(true); setError(""); setWorkflow(null); setGraph(null); setAdvanced(false); setPinOpen(false);
160
+ try {
161
+ const [response, queueResponse] = await Promise.all([
162
+ requestJson<WorkflowResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}`),
163
+ requestJson<QueueListResponse>("/v1/admin/queues").catch(() => ({ queues: [] })),
164
+ ]);
165
+ if (!active) return;
166
+ const detail = response.workflow;
167
+ let nextGraph = detail.draft?.graph ?? null;
168
+ if (!nextGraph && detail.activeVersionId) {
169
+ const published = await requestJson<WorkflowVersionResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/versions/${encodeURIComponent(detail.activeVersionId)}`);
170
+ if (!active) return;
171
+ nextGraph = published.version.graph;
172
+ }
173
+ if (!nextGraph) throw new Error("This workflow has no draft or published graph to open.");
174
+ const availableQueues = (queueResponse.queues || []).filter((queue) => queue.status === "active");
175
+ setWorkflow(detail); setGraph(structuredClone(nextGraph)); setGraphJson(JSON.stringify(nextGraph, null, 2));
176
+ setSelectedNodeId(nextGraph.entry_node_id || nextGraph.nodes[0]?.id || null); setQueues(availableQueues);
177
+ setPinQueueId(availableQueues[0]?.id || ""); setAuthorityMode(detail.activeVersion?.authorityMode || "assist");
178
+ setConsoleState(openWarning ? { kind: "message", heading: "Workflow created with a warning", detail: openWarning } : validationState(detail, "Stored validation"));
179
+ } catch (cause) { if (active) setError(errorMessage(cause, "The workflow could not be opened.")); }
180
+ finally { if (active) setLoading(false); }
181
+ };
182
+ void load();
183
+ return () => { active = false; };
184
+ }, [workflowId, openWarning]);
185
+
186
+ const saveGraph = async (nextGraph: WorkflowGraph): Promise<boolean> => {
187
+ if (!workflowId || !workflow?.draft || readOnly) return false;
188
+ setSaving(true); setError("");
189
+ try {
190
+ const result = await requestJson<WorkflowSaveResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/draft`, {
191
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ expected_revision: workflow.draft.revision, graph: nextGraph }),
192
+ });
193
+ setGraph(structuredClone(nextGraph)); setGraphJson(JSON.stringify(nextGraph, null, 2));
194
+ setWorkflow((current) => current?.draft ? { ...current, draft: { ...current.draft, revision: result.revision, graphDigest: result.graphDigest, graph: structuredClone(nextGraph), validation: result.validation, validationStatus: result.validation.valid ? "valid" : "invalid" } } : current);
195
+ setConsoleState({ kind: "validation", heading: "Draft saved and checked", validation: result.validation });
196
+ announceWorkflowChanged();
197
+ return true;
198
+ } catch (cause) { setError(errorMessage(cause, "The workflow draft could not be saved.")); return false; }
199
+ finally { setSaving(false); }
200
+ };
201
+
202
+ const fetchValidation = async (): Promise<WorkflowValidationResponse | null> => {
203
+ if (!workflowId) return null;
204
+ try {
205
+ return await requestJson<WorkflowValidationResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/validate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ authority_mode: authorityMode }) });
206
+ } catch (cause) {
207
+ const invalid = errorResponse<WorkflowValidationResponse>(cause, 422);
208
+ if (invalid?.validation) return invalid;
209
+ throw cause;
210
+ }
211
+ };
212
+ const fetchTest = async (): Promise<{ fixtureName: string; result: WorkflowTestResponse } | null> => {
213
+ 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;
223
+ }
224
+ };
225
+ const validate = async (): Promise<void> => {
226
+ setAction("validate"); setError("");
227
+ try {
228
+ const result = await fetchValidation();
229
+ if (result) {
230
+ setWorkflow((current) => current?.draft ? { ...current, draft: { ...current.draft, validation: result.validation, validationStatus: result.validation.valid ? "valid" : "invalid" } } : current);
231
+ setConsoleState({ kind: "validation", heading: "Validation result", validation: result.validation });
232
+ }
233
+ }
234
+ catch (cause) { setError(errorMessage(cause, "Validation failed.")); }
235
+ finally { setAction(""); }
236
+ };
237
+ const test = async (): Promise<void> => {
238
+ setAction("test"); setError("");
239
+ try { const result = await fetchTest(); if (result) setConsoleState({ kind: "test", ...result }); }
240
+ catch (cause) { setError(errorMessage(cause, "The fixture test failed.")); }
241
+ finally { setAction(""); }
242
+ };
243
+ const publish = async (): Promise<void> => {
244
+ if (!workflowId || !workflow?.draft || !canPublish) return;
245
+ setAction("publish"); setError("");
246
+ try {
247
+ const validation = await fetchValidation();
248
+ if (!validation) return;
249
+ setConsoleState({ kind: "validation", heading: "Publication check", validation: validation.validation });
250
+ if (!validation.validation.valid) return;
251
+ const tested = await fetchTest();
252
+ if (!tested) return;
253
+ setConsoleState({ kind: "test", ...tested });
254
+ if (tested.result.status !== "passed") return;
255
+ if (!window.confirm("Publish this exact tested draft as an immutable workflow version? Existing runs will not change.")) return;
256
+ const result = await requestJson<WorkflowPublishResponse>(`/v1/admin/workflows/${encodeURIComponent(workflowId)}/publish`, {
257
+ method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: workflow.draft.revision, authority_mode: authorityMode, test_evidence: { test_run_id: tested.result.testRunId } }),
258
+ });
259
+ 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();
262
+ } catch (cause) { setError(errorMessage(cause, "The workflow could not be published.")); }
263
+ finally { setAction(""); }
264
+ };
265
+ const pin = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
266
+ event.preventDefault();
267
+ if (!workflow?.activeVersionId || !pinQueueId || !canPin) return;
268
+ setAction("pin"); setError("");
269
+ try {
270
+ await requestJson<WorkflowPinResponse>(`/v1/admin/queues/${encodeURIComponent(pinQueueId)}/workflow-pin`, { method: "PUT", headers: mutationHeaders(), body: JSON.stringify({ workflow_version_id: workflow.activeVersionId, mode: pinMode }) });
271
+ const queue = queues.find((candidate) => candidate.id === pinQueueId);
272
+ setConsoleState({ kind: "pin", heading: "Workflow pinned", detail: `${workflow.name} is now the ${pinMode} workflow for ${queue?.name || "the selected queue"}. New eligible reports use it; active runs do not change.` });
273
+ setPinOpen(false); announceWorkflowChanged();
274
+ } catch (cause) { setError(errorMessage(cause, "The workflow could not be pinned.")); }
275
+ finally { setAction(""); }
276
+ };
277
+ const applyNode = async (node: WorkflowNode): Promise<void> => {
278
+ if (!graph) return;
279
+ const next = structuredClone(graph);
280
+ next.nodes = next.nodes.map((candidate) => candidate.id === node.id ? node : candidate);
281
+ await saveGraph(next);
282
+ };
283
+ const removeNode = async (node: WorkflowNode): Promise<void> => {
284
+ if (!graph || !window.confirm(`Remove “${node.name}” and reconnect its surrounding steps?`)) return;
285
+ const next = removeWorkflowNode(graph, node.id);
286
+ if (await saveGraph(next)) setSelectedNodeId(next.entry_node_id || next.nodes[0]?.id || null);
287
+ };
288
+ const addNode = async (kind: WorkflowNode["kind"], name: string): Promise<void> => {
289
+ if (!graph) return;
290
+ const inserted = insertWorkflowNode(graph, kind, name, queues[0]?.id);
291
+ if (await saveGraph(inserted.graph)) setSelectedNodeId(inserted.nodeId);
292
+ };
293
+ const applyGraphJson = async (): Promise<void> => {
294
+ setError("");
295
+ try {
296
+ const parsed: unknown = JSON.parse(graphJson);
297
+ if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as WorkflowGraph).nodes) || !Array.isArray((parsed as WorkflowGraph).edges)) throw new Error("The workflow graph must contain node and edge lists.");
298
+ const next = parsed as WorkflowGraph;
299
+ if (await saveGraph(next)) setSelectedNodeId(next.entry_node_id || next.nodes[0]?.id || null);
300
+ } catch (cause) { setError(errorMessage(cause, "The graph JSON is invalid.")); }
301
+ };
302
+
303
+ const subtitle = workflow?.draft
304
+ ? `${workflow.activeVersion ? `Published v${workflow.activeVersion.version} · ` : ""}Draft revision ${workflow.draft.revision}`
305
+ : workflow?.activeVersion ? `Published v${workflow.activeVersion.version} · read-only contract` : "Loading workflow…";
306
+ 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">
308
+ <div className="studio-toolbar">
309
+ <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
+ <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}
317
+ </div>
318
+ </div>
319
+ {loading ? <div className="workflow-studio-loading"><div className="skeleton-block" role="status" aria-label="Loading workflow studio" /></div> : graph && workflow ? <>
320
+ <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>
324
+ </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
+ </> : 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>
328
+ <p className="error studio-error" role="alert">{error}</p>
329
+ </dialog>
330
+ );
331
+ }
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
2
+ import { PageHeader } from "../components/PageHeader";
3
+ import { readable } from "../lib/format";
4
+ import { errorMessage, requestJson } from "../lib/http";
5
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
6
+ import { openWorkflowCreate, openWorkflowStudio, workflowEvents } from "./events";
7
+ import type { WorkflowListResponse, WorkflowSummary } from "./types";
8
+
9
+ function WorkflowCard({ workflow }: { workflow: WorkflowSummary }) {
10
+ const version = workflow.activeVersion?.version;
11
+ const authority = workflow.activeVersion?.authorityMode;
12
+ return (
13
+ <article className="object-card workflow-card">
14
+ <div className="object-card-head">
15
+ <span className="object-icon" aria-hidden="true">⌘</span>
16
+ <span className={`badge${workflow.status !== "active" ? " urgent" : ""}`}>{version ? `v${version}` : "Draft"}</span>
17
+ </div>
18
+ <h3>{workflow.name}</h3>
19
+ <p>{workflow.description || "No description yet."}</p>
20
+ <div className="object-card-meta">
21
+ <div><span>Purpose</span><strong>{readable(workflow.purpose)}</strong></div>
22
+ <div><span>Authority</span><strong>{readable(authority || "Not published")}</strong></div>
23
+ <div><span>Queue pins</span><strong>{workflow.activePinCount || 0}</strong></div>
24
+ </div>
25
+ <div className="object-card-footer">
26
+ <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
+ </div>
29
+ </article>
30
+ );
31
+ }
32
+
33
+ export function WorkflowWorkspace() {
34
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
35
+ const canManage = shell.session?.permissions.manageWorkflows === true;
36
+ const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
37
+ const [loading, setLoading] = useState(false);
38
+ const [refreshing, setRefreshing] = useState(false);
39
+ const [error, setError] = useState("");
40
+
41
+ const load = useCallback(async (refresh = false): Promise<void> => {
42
+ if (!canManage) { setWorkflows([]); setLoading(false); return; }
43
+ if (refresh) setRefreshing(true); else setLoading(true);
44
+ setError("");
45
+ try {
46
+ const response = await requestJson<WorkflowListResponse>("/v1/admin/workflows");
47
+ setWorkflows(Array.isArray(response.workflows) ? response.workflows : []);
48
+ } catch (cause) {
49
+ setError(errorMessage(cause, "Workflows could not be loaded."));
50
+ } finally {
51
+ setLoading(false);
52
+ setRefreshing(false);
53
+ }
54
+ }, [canManage]);
55
+
56
+ useEffect(() => {
57
+ if (shell.currentView === "workflows") void load();
58
+ }, [shell.currentView, shell.session?.actor.id, load]);
59
+ useEffect(() => {
60
+ const changed = (): void => { if (shell.currentView === "workflows") void load(true); };
61
+ window.addEventListener(workflowEvents.changed, changed);
62
+ return () => window.removeEventListener(workflowEvents.changed, changed);
63
+ }, [shell.currentView, load]);
64
+
65
+ return (
66
+ <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
+ <div className="surface-buttons">
69
+ <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
+ </div>
72
+ </PageHeader>
73
+ <div className="safety-boundary"><strong>Read-only steps inspect data. Actions change it.</strong><span>Publishing checks every failure path.</span></div>
74
+ <div className="object-grid workflow-list" aria-busy={loading || refreshing}>
75
+ {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>
77
+ )}
78
+ </div>
79
+ <p className="error surface-error" role="alert">{error}</p>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,17 @@
1
+ export const workflowEvents = {
2
+ openCreate: "safest:workflows:create-open",
3
+ openStudio: "safest:workflows:studio-open",
4
+ changed: "safest:workflows:changed",
5
+ } as const;
6
+
7
+ export function openWorkflowCreate(): void {
8
+ window.dispatchEvent(new Event(workflowEvents.openCreate));
9
+ }
10
+
11
+ export function openWorkflowStudio(workflowId: string, warning = ""): void {
12
+ window.dispatchEvent(new CustomEvent(workflowEvents.openStudio, { detail: { workflowId, warning } }));
13
+ }
14
+
15
+ export function announceWorkflowChanged(): void {
16
+ window.dispatchEvent(new Event(workflowEvents.changed));
17
+ }
@@ -0,0 +1,156 @@
1
+ import { readable } from "../lib/format";
2
+ import type { JsonObject, WorkflowEdge, WorkflowGraph, WorkflowNode, WorkflowNodeKind } from "./types";
3
+
4
+ export interface NodePosition { x: number; y: number }
5
+ export interface EdgeGeometry { kind: "forward" | "reverse" | "vertical"; start: NodePosition; end: NodePosition }
6
+
7
+ export const workflowNodeChoices: Array<{ kind: WorkflowNodeKind; name: string; detail: string; tone: string }> = [
8
+ { kind: "enrichment", name: "Enrichment", detail: "Read-only component", tone: "" },
9
+ { kind: "parallel_group", name: "Parallel group", detail: "Run independent branches", tone: "" },
10
+ { kind: "join", name: "Join", detail: "Required and optional results", tone: "" },
11
+ { kind: "condition", name: "Condition", detail: "Deterministic branch", tone: "" },
12
+ { kind: "ai_proposal", name: "AI proposal", detail: "Bounded structured suggestion", tone: "ai" },
13
+ { kind: "human_task", name: "Human task", detail: "Durable person-in-loop wait", tone: "human" },
14
+ { kind: "send_message", name: "Send message", detail: "Audience-safe communication", tone: "human" },
15
+ { kind: "wait_for_event", name: "Wait for reply", detail: "Resume from an external event", tone: "human" },
16
+ { kind: "decision", name: "Decision", detail: "Immutable policy outcome", tone: "human" },
17
+ { kind: "action", name: "Action", detail: "Approved customer-side effect", tone: "action" },
18
+ ];
19
+
20
+ export function nodeVisualClass(kind: WorkflowNodeKind): string {
21
+ if (kind === "human_task" || kind === "wait_for_event" || kind === "send_message" || kind === "decision") return "human";
22
+ if (kind === "action") return "action";
23
+ if (kind === "ai_proposal") return "ai";
24
+ return "";
25
+ }
26
+
27
+ export function nodePosition(node: Pick<WorkflowNode, "metadata">, index: number): NodePosition {
28
+ const x = Number(node.metadata?.x);
29
+ const y = Number(node.metadata?.y);
30
+ return { x: Number.isFinite(x) ? x : 35 + (index % 5) * 205, y: Number.isFinite(y) ? y : 70 + Math.floor(index / 5) * 125 };
31
+ }
32
+
33
+ export function canvasSize(graph: WorkflowGraph): { width: number; height: number; positions: Map<string, NodePosition> } {
34
+ const positions = new Map(graph.nodes.map((node, index) => [node.id, nodePosition(node, index)]));
35
+ return {
36
+ width: Math.max(920, ...[...positions.values()].map((position) => position.x + 200)),
37
+ height: Math.max(580, ...[...positions.values()].map((position) => position.y + 158)),
38
+ positions,
39
+ };
40
+ }
41
+
42
+ export function workflowEdgeGeometry(from: NodePosition, to: NodePosition, lane = 0): EdgeGeometry {
43
+ const nodeWidth = 168;
44
+ const nodeHeight = 78;
45
+ const targetClearance = 8;
46
+ const horizontalGap = to.x - (from.x + nodeWidth);
47
+ const laneOffset = lane * 9;
48
+ if (horizontalGap >= 0) return {
49
+ kind: "forward",
50
+ start: { x: from.x + nodeWidth, y: from.y + nodeHeight / 2 + laneOffset },
51
+ end: { x: to.x - targetClearance, y: to.y + nodeHeight / 2 + laneOffset },
52
+ };
53
+ const reverseGap = from.x - (to.x + nodeWidth);
54
+ if (reverseGap >= 0) return {
55
+ kind: "reverse",
56
+ start: { x: from.x, y: from.y + nodeHeight / 2 + laneOffset },
57
+ end: { x: to.x + nodeWidth + targetClearance, y: to.y + nodeHeight / 2 + laneOffset },
58
+ };
59
+ const goingDown = to.y >= from.y;
60
+ return {
61
+ kind: "vertical",
62
+ start: { x: from.x + nodeWidth / 2 + laneOffset, y: goingDown ? from.y + nodeHeight : from.y },
63
+ end: { x: to.x + nodeWidth / 2 + laneOffset, y: goingDown ? to.y - targetClearance : to.y + nodeHeight + targetClearance },
64
+ };
65
+ }
66
+
67
+ export function workflowEdgePath(geometry: EdgeGeometry, lane = 0): string {
68
+ const { start, end } = geometry;
69
+ if (geometry.kind === "forward") {
70
+ const bend = Math.max(28, Math.abs(end.x - start.x) * .42);
71
+ return `M ${start.x} ${start.y} C ${start.x + bend} ${start.y}, ${end.x - bend} ${end.y}, ${end.x} ${end.y}`;
72
+ }
73
+ if (geometry.kind === "reverse") {
74
+ const routeY = Math.min(start.y, end.y) - 42 - Math.abs(lane) * 12;
75
+ return `M ${start.x} ${start.y} C ${start.x - 34} ${start.y}, ${start.x - 34} ${routeY}, ${start.x - 68} ${routeY} L ${end.x + 68} ${routeY} C ${end.x + 34} ${routeY}, ${end.x + 34} ${end.y}, ${end.x} ${end.y}`;
76
+ }
77
+ const bend = Math.max(28, Math.abs(end.y - start.y) * .38);
78
+ const direction = end.y >= start.y ? 1 : -1;
79
+ return `M ${start.x} ${start.y} C ${start.x} ${start.y + bend * direction}, ${end.x} ${end.y - bend * direction}, ${end.x} ${end.y}`;
80
+ }
81
+
82
+ export function workflowEdgeLabel(edge: WorkflowEdge): string {
83
+ if (edge.label) return edge.label;
84
+ return ["success", "input", "completed", "recorded", "sent", "handed_off", "applied", "started", "received"].includes(edge.from.port) ? "" : readable(edge.from.port);
85
+ }
86
+
87
+ export function workflowEdgeTone(edge: WorkflowEdge): "standard" | "warning" | "exception" {
88
+ if (/error|failed|rejected|timeout/u.test(edge.from.port)) return "exception";
89
+ if (/uncertain|cancelled|expired/u.test(edge.from.port)) return "warning";
90
+ return "standard";
91
+ }
92
+
93
+ function defaultNodeConfig(kind: WorkflowNodeKind, queueId?: string): JsonObject {
94
+ if (kind === "human_task") return { queue_id: queueId || "select-a-queue", response_schema: { type: "object" } };
95
+ if (kind === "enrichment") return { component_version_id: "select-a-published-component-version" };
96
+ if (kind === "action") return { component_version_id: "select-a-published-action-version", approval_mode: "human" };
97
+ if (kind === "ai_proposal") return { model_alias_version_id: "select-a-model-alias-version", result_ports: ["proposed", "uncertain"] };
98
+ if (kind === "send_message") return { audience: "reporter", template_version_id: "select-a-template-version" };
99
+ if (kind === "wait_for_event") return { event_type: "reporter.reply" };
100
+ if (kind === "decision") return { policy_version_id: "select-a-policy-version" };
101
+ if (kind === "condition") return { cases: [], default_port: "default" };
102
+ if (kind === "join") return { required_ports: ["required"], optional_ports: [] };
103
+ if (kind === "parallel_group") return { branch_ids: [] };
104
+ return {};
105
+ }
106
+
107
+ function outputPort(kind: WorkflowNodeKind): string {
108
+ if (kind === "action") return "applied";
109
+ if (kind === "decision") return "recorded";
110
+ if (kind === "human_task") return "completed";
111
+ if (kind === "send_message") return "sent";
112
+ return "success";
113
+ }
114
+
115
+ export function insertWorkflowNode(graph: WorkflowGraph, kind: WorkflowNodeKind, name: string, queueId?: string): { graph: WorkflowGraph; nodeId: string } {
116
+ const next = structuredClone(graph);
117
+ const idBase = kind.replaceAll("_", "-");
118
+ let nodeId = idBase;
119
+ let suffix = 2;
120
+ while (next.nodes.some((node) => node.id === nodeId)) nodeId = `${idBase}-${suffix++}`;
121
+ const end = next.nodes.find((node) => node.kind === "end");
122
+ const index = end ? Math.max(1, next.nodes.indexOf(end)) : next.nodes.length;
123
+ next.nodes.splice(index, 0, {
124
+ id: nodeId,
125
+ kind,
126
+ name,
127
+ description: "",
128
+ input_mapping: {},
129
+ config: defaultNodeConfig(kind, queueId),
130
+ metadata: nodePosition({ metadata: undefined }, index),
131
+ });
132
+ const incoming = end ? next.edges.find((edge) => edge.to.node_id === end.id) : undefined;
133
+ if (incoming && end) {
134
+ incoming.to = { node_id: nodeId, port: "input" };
135
+ next.edges.push({ id: `edge-${nodeId}-${end.id}`.slice(0, 100), from: { node_id: nodeId, port: outputPort(kind) }, to: { node_id: end.id, port: "input" } });
136
+ }
137
+ return { graph: next, nodeId };
138
+ }
139
+
140
+ export function removeWorkflowNode(graph: WorkflowGraph, nodeId: string): WorkflowGraph {
141
+ const next = structuredClone(graph);
142
+ const node = next.nodes.find((candidate) => candidate.id === nodeId);
143
+ if (!node || node.kind === "start" || node.kind === "end") return next;
144
+ const incoming = next.edges.filter((edge) => edge.to.node_id === nodeId);
145
+ const outgoing = next.edges.filter((edge) => edge.from.node_id === nodeId);
146
+ next.nodes = next.nodes.filter((candidate) => candidate.id !== nodeId);
147
+ next.edges = next.edges.filter((edge) => edge.from.node_id !== nodeId && edge.to.node_id !== nodeId);
148
+ for (const before of incoming) for (const after of outgoing) {
149
+ next.edges.push({
150
+ id: `edge-${before.from.node_id.slice(0, 30)}-${after.to.node_id.slice(0, 30)}-${crypto.randomUUID().slice(0, 6)}`,
151
+ from: before.from,
152
+ to: after.to,
153
+ });
154
+ }
155
+ return next;
156
+ }