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,845 @@
1
+ import { canonicalJson } from "./audit";
2
+ import { reserveBudgets as reserveScopedBudgets, settleBudgets as settleScopedBudgets } from "./budget-control";
3
+ import { ConnectionExecutionError, executeConnectionJson } from "./connection-egress";
4
+ import { sha256 } from "./report-crypto";
5
+ import { evaluateInputMapping, validateJsonSchemaValue } from "./workflow-expressions";
6
+ import type { JsonObject, JsonValue, WorkflowNodeV1 } from "./workflow-platform-types";
7
+
8
+ interface ComponentVersionRow {
9
+ id: string;
10
+ componentId: string;
11
+ componentKey: string;
12
+ componentKind: string;
13
+ effectClass: "read_only" | "side_effecting";
14
+ definitionStatus: string;
15
+ implementationKind: "built_in" | "webhook" | "external_api" | "workers_ai" | "specialist_agent" | "message_template";
16
+ implementationReference: string;
17
+ inputSchemaJson: string;
18
+ outputSchemaJson: string;
19
+ allowedInputFieldsJson: string;
20
+ dataClassificationsJson: string;
21
+ connectionVersionId: string | null;
22
+ cachePolicyJson: string;
23
+ budgetPolicyJson: string;
24
+ toolManifestJson: string;
25
+ retiredAt: string | null;
26
+ }
27
+
28
+ interface ComponentRunReplayRow {
29
+ id: string;
30
+ state: string;
31
+ outputJson: string | null;
32
+ findingId: string | null;
33
+ attemptCount: number;
34
+ inputDigest: string;
35
+ }
36
+
37
+ interface RunContextRow {
38
+ runId: string;
39
+ installationId: string;
40
+ reportId: string;
41
+ workflowVersionId: string;
42
+ queueId: string | null;
43
+ queueVersionId: string | null;
44
+ authorityMode: string;
45
+ runInputJson: string;
46
+ publicReference: string;
47
+ source: string;
48
+ reportState: string;
49
+ priority: number;
50
+ reasonCode: string;
51
+ locale: string;
52
+ formVersionId: string | null;
53
+ policyVersionId: string | null;
54
+ routingRuleVersionId: string | null;
55
+ submittedAt: string;
56
+ receivedAt: string;
57
+ }
58
+
59
+ interface ComponentImplementationResult {
60
+ data: JsonObject;
61
+ provider?: string;
62
+ providerRequestId?: string;
63
+ connectionAttemptId?: string;
64
+ inputUnits?: number | null;
65
+ outputUnits?: number | null;
66
+ }
67
+
68
+ interface BudgetReservation {
69
+ id: string;
70
+ policyId: string;
71
+ windowStart: string;
72
+ }
73
+
74
+ export class ComponentExecutionError extends Error {
75
+ constructor(
76
+ readonly code: string,
77
+ readonly retryable: boolean,
78
+ readonly delaySeconds = 5,
79
+ ) {
80
+ super(code);
81
+ this.name = "ComponentExecutionError";
82
+ }
83
+ }
84
+
85
+ function now(): string {
86
+ return new Date().toISOString();
87
+ }
88
+
89
+ function parsedJson<T>(value: string | null, fallback: T): T {
90
+ if (!value) return fallback;
91
+ try {
92
+ return JSON.parse(value) as T;
93
+ } catch {
94
+ return fallback;
95
+ }
96
+ }
97
+
98
+ function asJsonObject(value: unknown, code: string): JsonObject {
99
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ComponentExecutionError(code, false);
100
+ try {
101
+ return JSON.parse(canonicalJson(value)) as JsonObject;
102
+ } catch {
103
+ throw new ComponentExecutionError(code, false);
104
+ }
105
+ }
106
+
107
+ function safeJsonObject(value: string): JsonObject {
108
+ try {
109
+ return asJsonObject(JSON.parse(value), "stored_json_invalid");
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ function expressionPaths(value: JsonValue, output = new Set<string>()): Set<string> {
116
+ if (typeof value === "string") {
117
+ for (const match of value.matchAll(/\$\.([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)/gu)) output.add(match[1]!);
118
+ return output;
119
+ }
120
+ if (Array.isArray(value)) {
121
+ for (const entry of value) expressionPaths(entry, output);
122
+ return output;
123
+ }
124
+ if (value && typeof value === "object") {
125
+ for (const entry of Object.values(value)) expressionPaths(entry, output);
126
+ }
127
+ return output;
128
+ }
129
+
130
+ export function assertMappedFieldsAllowed(mapping: Record<string, JsonValue>, allowedFields: string[]): void {
131
+ const normalized = allowedFields.map((field) => field.replace(/^\$\./u, "")).filter(Boolean);
132
+ for (const path of expressionPaths(mapping)) {
133
+ if (!normalized.some((allowed) => allowed === path || path.startsWith(`${allowed}.`))) {
134
+ throw new ComponentExecutionError("component_input_field_denied", false);
135
+ }
136
+ }
137
+ }
138
+
139
+ function isPublicUrlHost(hostname: string): boolean {
140
+ const host = hostname.toLowerCase().replace(/^\[|\]$/gu, "").replace(/\.$/u, "");
141
+ if (!host || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return false;
142
+ const octets = host.split(".").map(Number);
143
+ if (octets.length === 4 && octets.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
144
+ const [a, b] = octets as [number, number, number, number];
145
+ return !(a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224);
146
+ }
147
+ return !host.includes(":") || !(host === "::" || host === "::1" || /^(?:fc|fd|fe8|fe9|fea|feb)/u.test(host) || host.startsWith("::ffff:"));
148
+ }
149
+
150
+ export async function executeBuiltInComponent(reference: string, input: JsonObject): Promise<ComponentImplementationResult> {
151
+ if (reference === "identity-v1") return { data: input, provider: "safest_builtin" };
152
+ if (reference === "url-normalize-v1" || reference === "phishing-url-v1") {
153
+ if (typeof input.url !== "string" || input.url.length > 2_048) throw new ComponentExecutionError("component_url_invalid", false);
154
+ let url: URL;
155
+ try {
156
+ url = new URL(input.url);
157
+ } catch {
158
+ throw new ComponentExecutionError("component_url_invalid", false);
159
+ }
160
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || !isPublicUrlHost(url.hostname)) {
161
+ throw new ComponentExecutionError("component_url_unsafe", false);
162
+ }
163
+ url.hash = "";
164
+ const normalized = url.href;
165
+ const host = url.hostname.toLowerCase();
166
+ const signals = [
167
+ ...(host.startsWith("xn--") || host.includes(".xn--") ? ["punycode_hostname"] : []),
168
+ ...(/\b(?:login|verify|secure|account|password|wallet|invoice)\b/iu.test(`${host}${url.pathname}`) ? ["credential_lure_term"] : []),
169
+ ...(url.protocol !== "https:" ? ["plaintext_transport"] : []),
170
+ ...(host.split(".").length > 5 ? ["deep_subdomain"] : []),
171
+ ];
172
+ const data: JsonObject = {
173
+ normalized_url: normalized,
174
+ normalized_url_hash: `sha256:${await sha256(normalized)}`,
175
+ hostname_hash: `sha256:${await sha256(host)}`,
176
+ scheme: url.protocol.slice(0, -1),
177
+ signals,
178
+ };
179
+ if (reference === "phishing-url-v1") {
180
+ data.conclusion = signals.length >= 3 ? "likely_phishing" : signals.length >= 1 ? "suspicious" : "no_phishing_signal";
181
+ data.summary = signals.length ? `Observed ${signals.length} deterministic phishing-risk signal(s).` : "No deterministic phishing signal was observed; reputation and content checks were not available.";
182
+ data.uncertainty = ["dns_reputation_and_content_not_checked"];
183
+ data.confidence = signals.length ? Math.min(0.85, 0.45 + signals.length * 0.12) : 0.25;
184
+ }
185
+ return { data, provider: "safest_builtin" };
186
+ }
187
+ throw new ComponentExecutionError("component_implementation_unsupported", false);
188
+ }
189
+
190
+ function unwrapAiResponse(value: unknown): { data: JsonObject; inputUnits: number | null; outputUnits: number | null; requestId?: string } {
191
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ComponentExecutionError("component_ai_response_invalid", false);
192
+ const response = value as Record<string, unknown>;
193
+ const usage = response.usage && typeof response.usage === "object" && !Array.isArray(response.usage) ? response.usage as Record<string, unknown> : {};
194
+ let output = response.response;
195
+ if (output === undefined && Array.isArray(response.choices)) {
196
+ const choice = response.choices[0] as Record<string, unknown> | undefined;
197
+ const message = choice?.message && typeof choice.message === "object" && !Array.isArray(choice.message) ? choice.message as Record<string, unknown> : null;
198
+ output = message?.content;
199
+ }
200
+ if (typeof output === "string") {
201
+ try {
202
+ output = JSON.parse(output);
203
+ } catch {
204
+ throw new ComponentExecutionError("component_ai_response_invalid", false);
205
+ }
206
+ }
207
+ const unit = (candidate: unknown): number | null => typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0 ? candidate : null;
208
+ return {
209
+ data: asJsonObject(output, "component_ai_response_invalid"),
210
+ inputUnits: unit(usage.prompt_tokens ?? usage.input_tokens ?? usage.input_units),
211
+ outputUnits: unit(usage.completion_tokens ?? usage.output_tokens ?? usage.output_units),
212
+ ...(typeof response.request_id === "string" ? { requestId: response.request_id.slice(0, 200) } : {}),
213
+ };
214
+ }
215
+
216
+ async function executeImplementation(
217
+ env: Env,
218
+ component: ComponentVersionRow,
219
+ input: JsonObject,
220
+ componentRunId: string,
221
+ attempt: number,
222
+ ): Promise<ComponentImplementationResult> {
223
+ if (component.implementationKind === "built_in" || component.implementationKind === "specialist_agent") {
224
+ return executeBuiltInComponent(component.implementationReference, input);
225
+ }
226
+ if (component.implementationKind === "webhook" || component.implementationKind === "external_api") {
227
+ if (!component.connectionVersionId) throw new ComponentExecutionError("component_connection_missing", false);
228
+ const reference = component.implementationReference.trim();
229
+ const match = /^(?:(GET|POST|PUT|PATCH|DELETE|HEAD)\s+)?(\/[^\s]*)$/u.exec(reference);
230
+ if (!match) throw new ComponentExecutionError("component_operation_reference_invalid", false);
231
+ try {
232
+ const result = await executeConnectionJson(env, {
233
+ connectionVersionId: component.connectionVersionId,
234
+ componentRunId,
235
+ operationId: `${componentRunId}:egress`,
236
+ attemptNumber: attempt,
237
+ method: match[1] ?? "POST",
238
+ path: match[2]!,
239
+ body: { schema_version: "1", operation_id: componentRunId, component_version_id: component.id, input },
240
+ });
241
+ return { data: asJsonObject(result.data, "component_output_invalid"), connectionAttemptId: result.connectionAttemptId, provider: "connection" };
242
+ } catch (error) {
243
+ if (error instanceof ConnectionExecutionError) throw new ComponentExecutionError(error.code, error.retryable, error.delaySeconds);
244
+ throw error;
245
+ }
246
+ }
247
+ if (component.implementationKind === "workers_ai") {
248
+ if (!component.implementationReference.startsWith("@cf/")) throw new ComponentExecutionError("component_ai_model_invalid", false);
249
+ const ai = (env as unknown as { AI?: { run(model: string, input: Record<string, unknown>): Promise<unknown> } }).AI;
250
+ if (!ai) throw new ComponentExecutionError("component_ai_binding_unavailable", true, 30);
251
+ const outputSchema = parsedJson<JsonObject>(component.outputSchemaJson, {});
252
+ let result: unknown;
253
+ try {
254
+ result = await ai.run(component.implementationReference, {
255
+ messages: [
256
+ { role: "system", content: "You are a bounded safety enrichment. Use only the supplied JSON. Return only an object matching the provided schema. Never propose or execute an external action." },
257
+ { role: "user", content: canonicalJson(input) },
258
+ ],
259
+ response_format: { type: "json_schema", json_schema: outputSchema },
260
+ });
261
+ } catch {
262
+ throw new ComponentExecutionError("component_ai_provider_error", true, 15);
263
+ }
264
+ const unwrapped = unwrapAiResponse(result);
265
+ return { data: unwrapped.data, provider: "workers_ai", providerRequestId: unwrapped.requestId, inputUnits: unwrapped.inputUnits, outputUnits: unwrapped.outputUnits };
266
+ }
267
+ throw new ComponentExecutionError("component_implementation_unsupported", false);
268
+ }
269
+
270
+ export async function loadWorkflowExecutionContext(env: Env, runId: string, branchKey: string): Promise<{ row: RunContextRow; context: JsonObject; subjectType: string; subjectReference: string }> {
271
+ const row = await env.DB.prepare(`
272
+ SELECT wr.id AS runId, wr.installation_id AS installationId, wr.report_id AS reportId,
273
+ wr.workflow_version_id AS workflowVersionId, wr.queue_id AS queueId,
274
+ wr.queue_version_id AS queueVersionId, wr.authority_mode AS authorityMode,
275
+ wr.input_json AS runInputJson, r.public_reference AS publicReference,
276
+ r.source, r.state AS reportState, r.priority, r.reason_code AS reasonCode,
277
+ r.locale, r.form_version_id AS formVersionId, r.policy_version_id AS policyVersionId,
278
+ r.routing_rule_version_id AS routingRuleVersionId, r.submitted_at AS submittedAt,
279
+ r.received_at AS receivedAt
280
+ FROM workflow_runs wr JOIN reports r ON r.id = wr.report_id
281
+ WHERE wr.id = ?1 LIMIT 1
282
+ `).bind(runId).first<RunContextRow>();
283
+ if (!row) throw new ComponentExecutionError("component_report_context_missing", false);
284
+ const [targetResult, answersResult, stepsResult, findingsResult] = await Promise.all([
285
+ env.DB.prepare(`SELECT target_type, target_reference, owner_reference, customer_url, signed_facts_json FROM report_targets WHERE report_id = ?1 ORDER BY created_at, id LIMIT 50`).bind(row.reportId).all<{
286
+ target_type: string; target_reference: string; owner_reference: string | null; customer_url: string | null; signed_facts_json: string;
287
+ }>(),
288
+ env.DB.prepare(`SELECT field_key, value_json FROM report_answers WHERE report_id = ?1 ORDER BY field_key LIMIT 200`).bind(row.reportId).all<{ field_key: string; value_json: string }>(),
289
+ env.DB.prepare(`
290
+ SELECT s.node_id, o.inline_json FROM workflow_step_runs s
291
+ JOIN workflow_step_outputs o ON o.step_run_id = s.id
292
+ WHERE s.run_id = ?1 AND s.state = 'succeeded' AND s.branch_key IN ('', ?2)
293
+ ORDER BY s.finished_at, s.node_id
294
+ `).bind(row.runId, branchKey).all<{ node_id: string; inline_json: string | null }>(),
295
+ env.DB.prepare(`
296
+ SELECT finding_type, data_json FROM findings
297
+ WHERE report_id = ?1 AND status = 'available' AND (run_id = ?2 OR run_id IS NULL)
298
+ ORDER BY created_at, id LIMIT 100
299
+ `).bind(row.reportId, row.runId).all<{ finding_type: string; data_json: string }>(),
300
+ ]);
301
+ const targets = targetResult.results.map((target) => ({
302
+ type: target.target_type,
303
+ reference: target.target_reference,
304
+ owner_reference: target.owner_reference,
305
+ customer_url: target.customer_url,
306
+ signed_facts: safeJsonObject(target.signed_facts_json),
307
+ })) as JsonValue[];
308
+ const answers: JsonObject = {};
309
+ for (const answer of answersResult.results) {
310
+ try {
311
+ answers[answer.field_key] = JSON.parse(answer.value_json) as JsonValue;
312
+ } catch {
313
+ answers[answer.field_key] = null;
314
+ }
315
+ }
316
+ const steps: JsonObject = {};
317
+ for (const step of stepsResult.results) if (step.inline_json) steps[step.node_id] = safeJsonObject(step.inline_json);
318
+ const findings: JsonObject = {};
319
+ for (const finding of findingsResult.results) if (!(finding.finding_type in findings)) findings[finding.finding_type] = safeJsonObject(finding.data_json);
320
+ const context: JsonObject = {
321
+ run: { id: row.runId, workflow_version_id: row.workflowVersionId, authority_mode: row.authorityMode, input: safeJsonObject(row.runInputJson) },
322
+ report: {
323
+ id: row.reportId, public_reference: row.publicReference, source: row.source,
324
+ state: row.reportState, priority: row.priority, reason_code: row.reasonCode,
325
+ locale: row.locale, submitted_at: row.submittedAt, received_at: row.receivedAt,
326
+ form_version_id: row.formVersionId, policy_version_id: row.policyVersionId,
327
+ routing_rule_version_id: row.routingRuleVersionId,
328
+ },
329
+ queue: { id: row.queueId, version_id: row.queueVersionId },
330
+ target: targets[0] ?? null,
331
+ targets,
332
+ answers,
333
+ steps,
334
+ findings,
335
+ };
336
+ const firstTarget = targetResult.results[0];
337
+ return {
338
+ row,
339
+ context,
340
+ subjectType: firstTarget?.target_type ?? "report",
341
+ subjectReference: firstTarget?.target_reference ?? row.publicReference,
342
+ };
343
+ }
344
+
345
+ async function loadComponent(db: D1Database, componentVersionId: string): Promise<ComponentVersionRow> {
346
+ const row = await db.prepare(`
347
+ SELECT v.id, v.definition_id AS componentId, d.component_key AS componentKey,
348
+ d.component_kind AS componentKind, d.effect_class AS effectClass,
349
+ d.status AS definitionStatus, v.implementation_kind AS implementationKind,
350
+ v.implementation_reference AS implementationReference,
351
+ v.input_schema_json AS inputSchemaJson, v.output_schema_json AS outputSchemaJson,
352
+ v.allowed_input_fields_json AS allowedInputFieldsJson,
353
+ v.data_classifications_json AS dataClassificationsJson,
354
+ v.connection_version_id AS connectionVersionId, v.cache_policy_json AS cachePolicyJson,
355
+ v.budget_policy_json AS budgetPolicyJson, v.tool_manifest_json AS toolManifestJson,
356
+ v.retired_at AS retiredAt
357
+ FROM component_versions v JOIN component_definitions d ON d.id = v.definition_id
358
+ WHERE v.id = ?1 LIMIT 1
359
+ `).bind(componentVersionId).first<ComponentVersionRow>();
360
+ if (!row || row.retiredAt || row.definitionStatus !== "active") throw new ComponentExecutionError("component_version_unavailable", false);
361
+ if (row.effectClass !== "read_only") throw new ComponentExecutionError("component_effect_not_read_only", false);
362
+ return row;
363
+ }
364
+
365
+ async function reserveComponentBudgets(
366
+ db: D1Database,
367
+ component: ComponentVersionRow,
368
+ installationId: string,
369
+ workflowRunId: string,
370
+ operationId: string,
371
+ attempt: number,
372
+ ): Promise<BudgetReservation[]> {
373
+ const componentPolicy = parsedJson<Record<string, unknown>>(component.budgetPolicyJson, {});
374
+ if (Number.isInteger(componentPolicy.max_calls_per_run) && Number(componentPolicy.max_calls_per_run) >= 0) {
375
+ const used = await db.prepare(`
376
+ SELECT COUNT(*) AS count FROM component_runs
377
+ WHERE workflow_run_id = ?1 AND component_version_id = ?2 AND state IN ('running','succeeded')
378
+ `).bind(workflowRunId, component.id).first<{ count: number }>();
379
+ if (Number(used?.count ?? 0) > Number(componentPolicy.max_calls_per_run)) throw new ComponentExecutionError("component_run_budget_exhausted", false);
380
+ }
381
+ const policies = await db.prepare(`
382
+ SELECT id, limit_value AS limitValue, window_seconds AS windowSeconds,
383
+ behavior FROM budget_policies
384
+ WHERE installation_id = ?1 AND metric = 'calls' AND retired_at IS NULL
385
+ AND ((scope_type = 'installation' AND scope_id = ?1)
386
+ OR (scope_type = 'component' AND scope_id IN (?2, ?3)))
387
+ ORDER BY CASE scope_type WHEN 'component' THEN 0 ELSE 1 END, published_at DESC, id
388
+ `).bind(installationId, component.componentId, component.id).all<{
389
+ id: string; limitValue: number; windowSeconds: number; behavior: string;
390
+ }>();
391
+ const reservations: BudgetReservation[] = [];
392
+ try {
393
+ for (const policy of policies.results) {
394
+ const epochSeconds = Math.floor(Date.now() / 1_000);
395
+ const windowEpoch = Math.floor(epochSeconds / policy.windowSeconds) * policy.windowSeconds;
396
+ const windowStart = new Date(windowEpoch * 1_000).toISOString();
397
+ const reservationId = `${policy.id}:${operationId}:${attempt}`;
398
+ const existing = await db.prepare(`SELECT status FROM budget_reservations WHERE id = ?1 LIMIT 1`).bind(reservationId).first<{ status: string }>();
399
+ if (existing?.status === "reserved" || existing?.status === "committed") {
400
+ reservations.push({ id: reservationId, policyId: policy.id, windowStart });
401
+ continue;
402
+ }
403
+ await db.prepare(`
404
+ INSERT INTO budget_usage (policy_id, window_start, reserved_value, consumed_value, revision, updated_at)
405
+ VALUES (?1, ?2, 0, 0, 1, ?3) ON CONFLICT(policy_id, window_start) DO NOTHING
406
+ `).bind(policy.id, windowStart, now()).run();
407
+ const reserved = await db.prepare(`
408
+ UPDATE budget_usage SET reserved_value = reserved_value + 1,
409
+ revision = revision + 1, updated_at = ?3
410
+ WHERE policy_id = ?1 AND window_start = ?2
411
+ AND reserved_value + consumed_value + 1 <= ?4
412
+ `).bind(policy.id, windowStart, now(), policy.limitValue).run();
413
+ if (reserved.meta.changes !== 1) {
414
+ const retryable = policy.behavior === "defer";
415
+ throw new ComponentExecutionError(
416
+ policy.behavior === "fallback" ? "component_budget_fallback_required" : "component_budget_exhausted",
417
+ retryable,
418
+ retryable ? Math.max(5, policy.windowSeconds) : 5,
419
+ );
420
+ }
421
+ await db.prepare(`
422
+ INSERT INTO budget_reservations (
423
+ id, policy_id, window_start, operation_type, operation_id,
424
+ attempt_id, reserved_value, status, expires_at, created_at, updated_at
425
+ ) VALUES (?1, ?2, ?3, 'component', ?4, ?5, 1, 'reserved', ?6, ?7, ?7)
426
+ `).bind(
427
+ reservationId, policy.id, windowStart, operationId, String(attempt),
428
+ new Date(Date.now() + 15 * 60_000).toISOString(), now(),
429
+ ).run();
430
+ reservations.push({ id: reservationId, policyId: policy.id, windowStart });
431
+ }
432
+ return reservations;
433
+ } catch (error) {
434
+ await settleComponentBudgets(db, reservations, false);
435
+ throw error;
436
+ }
437
+ }
438
+
439
+ async function settleComponentBudgets(db: D1Database, reservations: BudgetReservation[], commit: boolean): Promise<void> {
440
+ const timestamp = now();
441
+ for (const reservation of reservations) {
442
+ const changed = await db.prepare(`
443
+ UPDATE budget_reservations SET status = ?2, committed_value = ?3, updated_at = ?4
444
+ WHERE id = ?1 AND status = 'reserved'
445
+ `).bind(reservation.id, commit ? "committed" : "released", commit ? 1 : null, timestamp).run();
446
+ if (changed.meta.changes !== 1) continue;
447
+ await db.prepare(`
448
+ UPDATE budget_usage SET reserved_value = MAX(0, reserved_value - 1),
449
+ consumed_value = consumed_value + ?3, revision = revision + 1, updated_at = ?4
450
+ WHERE policy_id = ?1 AND window_start = ?2
451
+ `).bind(reservation.policyId, reservation.windowStart, commit ? 1 : 0, timestamp).run();
452
+ }
453
+ }
454
+
455
+ async function ensureStepAndAttempt(
456
+ env: Env,
457
+ input: { runId: string; node: WorkflowNodeV1; branchKey: string; stepRunId: string; inputDigest: string },
458
+ ): Promise<number> {
459
+ const timestamp = now();
460
+ await env.DB.prepare(`
461
+ INSERT INTO workflow_step_runs (
462
+ id, run_id, node_id, node_type, branch_key, occurrence, state,
463
+ input_digest, started_at, updated_at
464
+ ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 'running', ?6, ?7, ?7)
465
+ ON CONFLICT(run_id, node_id, branch_key, occurrence) DO UPDATE SET
466
+ state = 'running', input_digest = excluded.input_digest,
467
+ started_at = COALESCE(workflow_step_runs.started_at, excluded.started_at),
468
+ updated_at = excluded.updated_at
469
+ `).bind(input.stepRunId, input.runId, input.node.id, input.node.kind, input.branchKey, input.inputDigest, timestamp).run();
470
+ const latest = await env.DB.prepare(`SELECT COALESCE(MAX(attempt), 0) AS attempt FROM workflow_step_attempts WHERE step_run_id = ?1`)
471
+ .bind(input.stepRunId).first<{ attempt: number }>();
472
+ const attempt = Number(latest?.attempt ?? 0) + 1;
473
+ await env.DB.prepare(`
474
+ INSERT INTO workflow_step_attempts (
475
+ id, step_run_id, attempt, operation_id, status, request_digest, started_at
476
+ ) VALUES (?1, ?2, ?3, ?4, 'running', ?5, ?6)
477
+ `).bind(crypto.randomUUID(), input.stepRunId, attempt, `${input.stepRunId}:attempt:${attempt}`, input.inputDigest, timestamp).run();
478
+ return attempt;
479
+ }
480
+
481
+ async function finishStepAttempt(
482
+ db: D1Database,
483
+ stepRunId: string,
484
+ attempt: number,
485
+ input: { status: "succeeded" | "retryable_failed" | "permanent_failed"; outputDigest?: string; errorCode?: string },
486
+ ): Promise<void> {
487
+ const timestamp = now();
488
+ await db.batch([
489
+ db.prepare(`
490
+ UPDATE workflow_step_attempts SET status = ?3, response_digest = ?4,
491
+ error_code = ?5, error_class = ?6, finished_at = ?7
492
+ WHERE step_run_id = ?1 AND attempt = ?2
493
+ `).bind(
494
+ stepRunId, attempt, input.status, input.outputDigest ?? null, input.errorCode ?? null,
495
+ input.status === "retryable_failed" ? "retryable" : input.status === "permanent_failed" ? "permanent" : null,
496
+ timestamp,
497
+ ),
498
+ ...(input.status === "succeeded" ? [] : [db.prepare(`
499
+ UPDATE workflow_step_runs SET state = 'failed', updated_at = ?2 WHERE id = ?1
500
+ `).bind(stepRunId, timestamp)]),
501
+ ]);
502
+ }
503
+
504
+ function findingSummary(data: JsonObject, componentKey: string): string {
505
+ if (typeof data.summary === "string" && data.summary.trim()) return data.summary.trim().slice(0, 1_000);
506
+ if (typeof data.conclusion === "string") return `${componentKey} concluded ${data.conclusion}.`.slice(0, 1_000);
507
+ return `${componentKey} completed successfully.`;
508
+ }
509
+
510
+ function confidence(data: JsonObject): number | null {
511
+ return typeof data.confidence === "number" && data.confidence >= 0 && data.confidence <= 1 ? data.confidence : null;
512
+ }
513
+
514
+ export async function executeReadOnlyComponent(
515
+ env: Env,
516
+ input: { runId: string; node: WorkflowNodeV1; branchKey: string; stepRunId: string },
517
+ ): Promise<Record<string, unknown>> {
518
+ const componentVersionId = typeof input.node.config.component_version_id === "string" ? input.node.config.component_version_id : null;
519
+ if (!componentVersionId) throw new ComponentExecutionError("component_version_required", false);
520
+ const [component, runtime] = await Promise.all([
521
+ loadComponent(env.DB, componentVersionId),
522
+ loadWorkflowExecutionContext(env, input.runId, input.branchKey),
523
+ ]);
524
+ const allowedFields = parsedJson<string[]>(component.allowedInputFieldsJson, []);
525
+ assertMappedFieldsAllowed(input.node.inputMapping, allowedFields);
526
+ const mappedInput = evaluateInputMapping(input.node.inputMapping, runtime.context);
527
+ const inputSchema = parsedJson<JsonObject>(component.inputSchemaJson, {});
528
+ const inputErrors = validateJsonSchemaValue(mappedInput, inputSchema);
529
+ if (inputErrors.length) throw new ComponentExecutionError("component_input_schema_invalid", false);
530
+ const inputJson = canonicalJson(mappedInput);
531
+ const inputDigest = `sha256:${await sha256(inputJson)}`;
532
+ const componentRunId = `${input.stepRunId}:component`;
533
+ const replay = await env.DB.prepare(`
534
+ SELECT id, state, output_json AS outputJson, finding_id AS findingId,
535
+ attempt_count AS attemptCount, input_digest AS inputDigest
536
+ FROM component_runs WHERE operation_id = ?1 LIMIT 1
537
+ `).bind(componentRunId).first<ComponentRunReplayRow>();
538
+ if (replay && replay.inputDigest !== inputDigest) throw new ComponentExecutionError("component_operation_input_changed", false);
539
+ if (replay?.state === "succeeded" && replay.outputJson && replay.findingId) {
540
+ const data = safeJsonObject(replay.outputJson);
541
+ return { port: "success", componentRunId: replay.id, findingId: replay.findingId, cacheHit: false, data };
542
+ }
543
+ const attempt = await ensureStepAndAttempt(env, { ...input, inputDigest });
544
+ const timestamp = now();
545
+ await env.DB.prepare(`
546
+ INSERT INTO component_runs (
547
+ id, installation_id, report_id, workflow_run_id, step_run_id,
548
+ component_version_id, operation_id, effect_class, state, input_json,
549
+ input_digest, attempt_count, started_at, created_at, updated_at
550
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?1, 'read_only', 'running', ?7, ?8, 1, ?9, ?9, ?9)
551
+ ON CONFLICT(operation_id) DO UPDATE SET state = 'running',
552
+ attempt_count = component_runs.attempt_count + 1,
553
+ error_code = NULL, started_at = COALESCE(component_runs.started_at, excluded.started_at),
554
+ updated_at = excluded.updated_at
555
+ `).bind(
556
+ componentRunId, runtime.row.installationId, runtime.row.reportId, runtime.row.runId,
557
+ input.stepRunId, component.id, inputJson, inputDigest, timestamp,
558
+ ).run();
559
+ const cachePolicy = parsedJson<Record<string, unknown>>(component.cachePolicyJson, {});
560
+ const subjectKeyHash = `sha256:${await sha256(`${runtime.subjectType}:${runtime.subjectReference}`)}`;
561
+ if (cachePolicy.enabled === true && input.node.config.cache_use !== "fresh_only") {
562
+ const cached = await env.DB.prepare(`
563
+ SELECT c.output_json AS outputJson, c.output_digest AS outputDigest, c.finding_id AS findingId
564
+ FROM component_cache_entries c JOIN findings f ON f.id = c.finding_id
565
+ WHERE c.component_version_id = ?1 AND c.subject_key_hash = ?2 AND c.input_digest = ?3
566
+ AND c.expires_at > ?4 AND f.status = 'available' LIMIT 1
567
+ `).bind(component.id, subjectKeyHash, inputDigest, timestamp).first<{ outputJson: string; outputDigest: string; findingId: string }>();
568
+ if (cached) {
569
+ await env.DB.batch([
570
+ env.DB.prepare(`
571
+ UPDATE component_runs SET state = 'succeeded', output_json = ?2, output_digest = ?3,
572
+ finding_id = ?4, cache_hit = 1, finished_at = ?5, updated_at = ?5 WHERE id = ?1
573
+ `).bind(componentRunId, cached.outputJson, cached.outputDigest, cached.findingId, timestamp),
574
+ env.DB.prepare(`UPDATE workflow_step_attempts SET status = 'succeeded', response_digest = ?3, finished_at = ?4 WHERE step_run_id = ?1 AND attempt = ?2`)
575
+ .bind(input.stepRunId, attempt, cached.outputDigest, timestamp),
576
+ ]);
577
+ return { port: "success", componentRunId, findingId: cached.findingId, cacheHit: true, data: safeJsonObject(cached.outputJson) };
578
+ }
579
+ }
580
+ if (component.implementationKind === "workers_ai" && runtime.row.authorityMode === "off") {
581
+ throw new ComponentExecutionError("component_ai_authority_disabled", false);
582
+ }
583
+ const budgetReservations = await reserveComponentBudgets(
584
+ env.DB,
585
+ component,
586
+ runtime.row.installationId,
587
+ runtime.row.runId,
588
+ componentRunId,
589
+ attempt,
590
+ );
591
+ const aiRunId = component.implementationKind === "workers_ai" ? `${componentRunId}:ai` : null;
592
+ if (aiRunId) {
593
+ const outputSchemaDigest = `sha256:${await sha256(component.outputSchemaJson)}`;
594
+ await env.DB.prepare(`
595
+ INSERT INTO workflow_ai_runs (
596
+ id, installation_id, report_id, workflow_run_id, step_run_id,
597
+ component_version_id, authority_mode, provider, model, prompt_version_id,
598
+ output_schema_digest, status, input_digest, started_at
599
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'workers_ai', ?8, ?9, ?10, 'running', ?11, ?12)
600
+ ON CONFLICT(id) DO UPDATE SET
601
+ status = CASE WHEN workflow_ai_runs.status = 'succeeded' THEN 'succeeded' ELSE 'running' END,
602
+ started_at = COALESCE(workflow_ai_runs.started_at, excluded.started_at),
603
+ finished_at = CASE WHEN workflow_ai_runs.status = 'succeeded' THEN workflow_ai_runs.finished_at ELSE NULL END
604
+ `).bind(
605
+ aiRunId, runtime.row.installationId, runtime.row.reportId, runtime.row.runId,
606
+ input.stepRunId, component.id, runtime.row.authorityMode,
607
+ component.implementationReference, `component:${component.id}:system-v1`,
608
+ outputSchemaDigest, inputDigest, timestamp,
609
+ ).run();
610
+ }
611
+ try {
612
+ const implementation = await executeImplementation(env, component, mappedInput, componentRunId, attempt);
613
+ const outputJson = canonicalJson(implementation.data);
614
+ if (new TextEncoder().encode(outputJson).byteLength > 262_144) throw new ComponentExecutionError("component_output_too_large", false);
615
+ const outputErrors = validateJsonSchemaValue(implementation.data, parsedJson<JsonObject>(component.outputSchemaJson, {}));
616
+ if (outputErrors.length) throw new ComponentExecutionError("component_output_schema_invalid", false);
617
+ const outputDigest = `sha256:${await sha256(outputJson)}`;
618
+ const findingId = crypto.randomUUID();
619
+ const finishedAt = now();
620
+ const expiresAt = cachePolicy.enabled === true && Number.isInteger(cachePolicy.ttl_seconds) && Number(cachePolicy.ttl_seconds) > 0
621
+ ? new Date(Date.now() + Math.min(Number(cachePolicy.ttl_seconds), 2_678_400) * 1_000).toISOString() : null;
622
+ const provenance = {
623
+ schema_version: "1", component_version_id: component.id, component_run_id: componentRunId,
624
+ implementation_kind: component.implementationKind, provider: implementation.provider ?? "unknown",
625
+ ...(implementation.connectionAttemptId ? { connection_attempt_id: implementation.connectionAttemptId } : {}),
626
+ ...(implementation.providerRequestId ? { provider_request_id: implementation.providerRequestId } : {}),
627
+ input_units: implementation.inputUnits ?? null, output_units: implementation.outputUnits ?? null,
628
+ };
629
+ const uncertainty = Array.isArray(implementation.data.uncertainty) ? implementation.data.uncertainty : [];
630
+ const statements: D1PreparedStatement[] = [
631
+ env.DB.prepare(`
632
+ INSERT INTO findings (
633
+ id, installation_id, report_id, run_id, step_run_id, component_version_id,
634
+ finding_type, subject_type, subject_reference, status, confidence, summary,
635
+ data_json, provenance_json, uncertainty_json, input_digest, output_digest,
636
+ expires_at, created_at
637
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'available', ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
638
+ `).bind(
639
+ findingId, runtime.row.installationId, runtime.row.reportId, runtime.row.runId,
640
+ input.stepRunId, component.id, component.componentKey, runtime.subjectType,
641
+ runtime.subjectReference, confidence(implementation.data), findingSummary(implementation.data, component.componentKey),
642
+ outputJson, canonicalJson(provenance), canonicalJson(uncertainty), inputDigest, outputDigest,
643
+ expiresAt, finishedAt,
644
+ ),
645
+ env.DB.prepare(`
646
+ UPDATE component_runs SET state = 'succeeded', output_json = ?2, output_digest = ?3,
647
+ finding_id = ?4, finished_at = ?5, updated_at = ?5 WHERE id = ?1
648
+ `).bind(componentRunId, outputJson, outputDigest, findingId, finishedAt),
649
+ ];
650
+ if (expiresAt) statements.push(env.DB.prepare(`
651
+ INSERT INTO component_cache_entries (
652
+ id, installation_id, component_version_id, subject_key_hash, input_digest,
653
+ finding_id, output_json, output_digest, created_at, expires_at
654
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
655
+ ON CONFLICT(component_version_id, subject_key_hash, input_digest) DO UPDATE SET
656
+ finding_id = excluded.finding_id, output_json = excluded.output_json,
657
+ output_digest = excluded.output_digest, created_at = excluded.created_at,
658
+ expires_at = excluded.expires_at
659
+ `).bind(
660
+ crypto.randomUUID(), runtime.row.installationId, component.id, subjectKeyHash,
661
+ inputDigest, findingId, outputJson, outputDigest, finishedAt, expiresAt,
662
+ ));
663
+ await env.DB.batch(statements);
664
+ if (aiRunId) {
665
+ await env.DB.prepare(`
666
+ UPDATE workflow_ai_runs SET status = 'succeeded', output_json = ?2,
667
+ output_digest = ?3, validation_errors_json = '[]', input_units = ?4,
668
+ output_units = ?5, provider_request_id = ?6, finished_at = ?7
669
+ WHERE id = ?1
670
+ `).bind(
671
+ aiRunId, outputJson, outputDigest, implementation.inputUnits ?? null,
672
+ implementation.outputUnits ?? null, implementation.providerRequestId ?? null,
673
+ finishedAt,
674
+ ).run();
675
+ }
676
+ await settleComponentBudgets(env.DB, budgetReservations, true);
677
+ await finishStepAttempt(env.DB, input.stepRunId, attempt, { status: "succeeded", outputDigest });
678
+ return { port: "success", componentRunId, findingId, cacheHit: false, data: implementation.data };
679
+ } catch (error) {
680
+ const failure = error instanceof ComponentExecutionError
681
+ ? error : new ComponentExecutionError("component_execution_failed", true, 5);
682
+ const finishedAt = now();
683
+ await env.DB.prepare(`
684
+ UPDATE component_runs SET state = 'failed', error_code = ?2, finished_at = ?3,
685
+ updated_at = ?3 WHERE id = ?1
686
+ `).bind(componentRunId, failure.code, finishedAt).run();
687
+ if (aiRunId) {
688
+ const invalid = ["component_ai_response_invalid", "component_output_schema_invalid", "component_output_invalid"].includes(failure.code);
689
+ await env.DB.prepare(`
690
+ UPDATE workflow_ai_runs SET status = ?2, validation_errors_json = ?3,
691
+ finished_at = ?4 WHERE id = ?1 AND status != 'succeeded'
692
+ `).bind(
693
+ aiRunId, invalid ? "invalid" : "failed",
694
+ canonicalJson([{ code: failure.code }]), finishedAt,
695
+ ).run();
696
+ }
697
+ await finishStepAttempt(env.DB, input.stepRunId, attempt, {
698
+ status: failure.retryable ? "retryable_failed" : "permanent_failed",
699
+ errorCode: failure.code,
700
+ });
701
+ await settleComponentBudgets(env.DB, budgetReservations, false);
702
+ throw failure;
703
+ }
704
+ }
705
+
706
+ export async function executeAssistantReadOnlyComponent(
707
+ env: Env,
708
+ input: {
709
+ reportId: string;
710
+ componentVersionId: string;
711
+ componentInput: JsonObject;
712
+ operationId: string;
713
+ actorId: string;
714
+ source?: "assistant" | "operator" | "routing";
715
+ },
716
+ ): Promise<{ componentRunId: string; findingId: string; data: JsonObject; idempotentReplay: boolean }> {
717
+ const [component, report] = await Promise.all([
718
+ loadComponent(env.DB, input.componentVersionId),
719
+ env.DB.prepare(`
720
+ SELECT r.id, r.installation_id AS installationId, r.queue_id AS queueId,
721
+ r.public_reference AS publicReference,
722
+ (SELECT target_type FROM report_targets WHERE report_id = r.id ORDER BY created_at, id LIMIT 1) AS subjectType,
723
+ (SELECT target_reference FROM report_targets WHERE report_id = r.id ORDER BY created_at, id LIMIT 1) AS subjectReference
724
+ FROM reports r WHERE r.id = ?1 LIMIT 1
725
+ `).bind(input.reportId).first<{
726
+ id: string; installationId: string; queueId: string | null; publicReference: string;
727
+ subjectType: string | null; subjectReference: string | null;
728
+ }>(),
729
+ ]);
730
+ if (!report) throw new ComponentExecutionError("component_report_context_missing", false);
731
+ const inputErrors = validateJsonSchemaValue(input.componentInput, parsedJson<JsonObject>(component.inputSchemaJson, {}));
732
+ if (inputErrors.length) throw new ComponentExecutionError("component_input_schema_invalid", false);
733
+ const inputJson = canonicalJson(input.componentInput);
734
+ if (new TextEncoder().encode(inputJson).byteLength > 131_072) throw new ComponentExecutionError("component_input_too_large", false);
735
+ const inputDigest = `sha256:${await sha256(inputJson)}`;
736
+ const source = input.source ?? "assistant";
737
+ const componentRunId = `${source}:${input.operationId}:component`;
738
+ const replay = await env.DB.prepare(`SELECT id, state, output_json AS outputJson, finding_id AS findingId, input_digest AS inputDigest FROM component_runs WHERE operation_id = ?1 LIMIT 1`)
739
+ .bind(componentRunId).first<{ id: string; state: string; outputJson: string | null; findingId: string | null; inputDigest: string }>();
740
+ if (replay && replay.inputDigest !== inputDigest) throw new ComponentExecutionError("component_operation_input_changed", false);
741
+ if (replay?.state === "succeeded" && replay.outputJson && replay.findingId) {
742
+ return { componentRunId: replay.id, findingId: replay.findingId, data: safeJsonObject(replay.outputJson), idempotentReplay: true };
743
+ }
744
+ const timestamp = now();
745
+ await env.DB.prepare(`
746
+ INSERT INTO component_runs (
747
+ id, installation_id, report_id, component_version_id, operation_id,
748
+ effect_class, state, input_json, input_digest, attempt_count,
749
+ started_at, created_at, updated_at
750
+ ) VALUES (?1, ?2, ?3, ?4, ?1, 'read_only', 'running', ?5, ?6, 1, ?7, ?7, ?7)
751
+ ON CONFLICT(operation_id) DO UPDATE SET
752
+ state = 'running', attempt_count = component_runs.attempt_count + 1,
753
+ error_code = NULL, updated_at = excluded.updated_at
754
+ `).bind(componentRunId, report.installationId, report.id, component.id, inputJson, inputDigest, timestamp).run();
755
+ const current = await env.DB.prepare(`SELECT attempt_count AS attempt FROM component_runs WHERE id = ?1`).bind(componentRunId).first<{ attempt: number }>();
756
+ const attempt = Number(current?.attempt ?? 1);
757
+ const reservations = await reserveScopedBudgets(env.DB, {
758
+ installationId: report.installationId,
759
+ scopes: [
760
+ ...(source === "assistant" ? [{ type: "assistant" as const, id: input.actorId }] : []),
761
+ { type: "component", id: component.id },
762
+ ...(report.queueId ? [{ type: "queue", id: report.queueId }] : []),
763
+ ],
764
+ metric: "calls", amount: 1, operationType: `${source}_component`,
765
+ operationId: componentRunId, attemptId: String(attempt),
766
+ });
767
+ try {
768
+ const implementation = await executeImplementation(env, component, input.componentInput, componentRunId, attempt);
769
+ const outputJson = canonicalJson(implementation.data);
770
+ if (new TextEncoder().encode(outputJson).byteLength > 262_144) throw new ComponentExecutionError("component_output_too_large", false);
771
+ if (validateJsonSchemaValue(implementation.data, parsedJson<JsonObject>(component.outputSchemaJson, {})).length) {
772
+ throw new ComponentExecutionError("component_output_schema_invalid", false);
773
+ }
774
+ const outputDigest = `sha256:${await sha256(outputJson)}`;
775
+ const findingId = crypto.randomUUID();
776
+ const finishedAt = now();
777
+ await env.DB.batch([
778
+ env.DB.prepare(`
779
+ INSERT INTO findings (
780
+ id, installation_id, report_id, component_version_id,
781
+ finding_type, subject_type, subject_reference, status, confidence,
782
+ summary, data_json, provenance_json, uncertainty_json,
783
+ input_digest, output_digest, created_at
784
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'available', ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
785
+ `).bind(
786
+ findingId, report.installationId, report.id, component.id, component.componentKey,
787
+ report.subjectType ?? "report", report.subjectReference ?? report.publicReference,
788
+ confidence(implementation.data), findingSummary(implementation.data, component.componentKey),
789
+ outputJson, canonicalJson({
790
+ schema_version: "1",
791
+ source: source === "assistant" ? "assistant_safe_enrichment" : source === "routing" ? "routing_intake_enrichment" : "operator_explicit_enrichment",
792
+ actor_id: input.actorId,
793
+ component_version_id: component.id, component_run_id: componentRunId,
794
+ implementation_kind: component.implementationKind, provider: implementation.provider ?? "unknown",
795
+ connection_attempt_id: implementation.connectionAttemptId ?? null,
796
+ provider_request_id: implementation.providerRequestId ?? null,
797
+ input_units: implementation.inputUnits ?? null, output_units: implementation.outputUnits ?? null,
798
+ }), canonicalJson(Array.isArray(implementation.data.uncertainty) ? implementation.data.uncertainty : []),
799
+ inputDigest, outputDigest, finishedAt,
800
+ ),
801
+ env.DB.prepare(`UPDATE component_runs SET state = 'succeeded', output_json = ?2, output_digest = ?3, finding_id = ?4, finished_at = ?5, updated_at = ?5 WHERE id = ?1`)
802
+ .bind(componentRunId, outputJson, outputDigest, findingId, finishedAt),
803
+ env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'finding', ?6, ?7, ?8)`)
804
+ .bind(
805
+ crypto.randomUUID(), `${source}-enrichment:${componentRunId}`,
806
+ source === "assistant" ? "assistant.enrichment_executed" : source === "routing" ? "routing.intake_enrichment_executed" : "report.enrichment_executed",
807
+ source === "routing" ? "system" : "operator_user", input.actorId, findingId,
808
+ canonicalJson({ reportId: report.id, componentVersionId: component.id, componentRunId, outputDigest }),
809
+ finishedAt,
810
+ ),
811
+ ]);
812
+ await settleScopedBudgets(env.DB, reservations, true);
813
+ return { componentRunId, findingId, data: implementation.data, idempotentReplay: false };
814
+ } catch (error) {
815
+ const failure = error instanceof ComponentExecutionError ? error : new ComponentExecutionError("component_execution_failed", true, 5);
816
+ await env.DB.prepare(`UPDATE component_runs SET state = 'failed', error_code = ?2, finished_at = ?3, updated_at = ?3 WHERE id = ?1`)
817
+ .bind(componentRunId, failure.code, now()).run();
818
+ await settleScopedBudgets(env.DB, reservations, false);
819
+ throw failure;
820
+ }
821
+ }
822
+
823
+ export async function executeRoutingReadOnlyComponent(
824
+ env: Env,
825
+ input: {
826
+ reportId: string;
827
+ componentVersionId: string;
828
+ inputMapping: Record<string, JsonValue>;
829
+ context: JsonObject;
830
+ operationId: string;
831
+ },
832
+ ): Promise<{ componentKey: string; componentRunId: string; findingId: string; data: JsonObject; idempotentReplay: boolean }> {
833
+ const component = await loadComponent(env.DB, input.componentVersionId);
834
+ assertMappedFieldsAllowed(input.inputMapping, parsedJson<string[]>(component.allowedInputFieldsJson, []));
835
+ const componentInput = evaluateInputMapping(input.inputMapping, input.context);
836
+ const result = await executeAssistantReadOnlyComponent(env, {
837
+ reportId: input.reportId,
838
+ componentVersionId: component.id,
839
+ componentInput,
840
+ operationId: input.operationId,
841
+ actorId: "routing-service",
842
+ source: "routing",
843
+ });
844
+ return { componentKey: component.componentKey, ...result };
845
+ }