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.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- 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
|
+
}
|