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,857 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { evaluateAiFailurePause } from "./report-ai-quality";
|
|
3
|
+
import type { AiJob, DeliveryJob, ReportState } from "./report-types";
|
|
4
|
+
import type { AiMode, ValidatedAiResult } from "./report-ai-validation";
|
|
5
|
+
import { validateAiResult } from "./report-ai-validation";
|
|
6
|
+
|
|
7
|
+
const AI_MAX_ATTEMPTS = 3;
|
|
8
|
+
const AI_RETRY_DELAYS = [30, 120, 600] as const;
|
|
9
|
+
const AI_TIMEOUT_MS = 15_000;
|
|
10
|
+
const AI_OUTPUT_LIMIT = 16 * 1024;
|
|
11
|
+
|
|
12
|
+
export interface AiBinding {
|
|
13
|
+
run(model: string, input: Record<string, unknown>): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AiConfigRow {
|
|
17
|
+
id: string;
|
|
18
|
+
queue_id: string;
|
|
19
|
+
queue_version_id: string | null;
|
|
20
|
+
version: number;
|
|
21
|
+
mode: AiMode;
|
|
22
|
+
enabled: number;
|
|
23
|
+
provider: "workers_ai";
|
|
24
|
+
model: string;
|
|
25
|
+
input_cost_microusd_per_million: number;
|
|
26
|
+
output_cost_microusd_per_million: number;
|
|
27
|
+
prompt_version: string;
|
|
28
|
+
policy_version_id: string;
|
|
29
|
+
eligible_form_versions_json: string;
|
|
30
|
+
eligible_sources_json: string;
|
|
31
|
+
eligible_reason_codes_json: string;
|
|
32
|
+
eligible_target_types_json: string;
|
|
33
|
+
allowed_fields_json: string;
|
|
34
|
+
allowed_outcomes_json: string;
|
|
35
|
+
allowed_action_codes_json: string;
|
|
36
|
+
allowed_template_ids_json: string;
|
|
37
|
+
confidence_threshold: number;
|
|
38
|
+
max_follow_up_turns: number;
|
|
39
|
+
max_wait_hours: number;
|
|
40
|
+
daily_report_budget: number;
|
|
41
|
+
daily_cost_budget_microusd: number;
|
|
42
|
+
quality_sample_rate: number;
|
|
43
|
+
minimum_shadow_runs: number;
|
|
44
|
+
minimum_quality_reviews: number;
|
|
45
|
+
maximum_failure_rate: number;
|
|
46
|
+
maximum_disagreement_rate: number;
|
|
47
|
+
fallback_queue_id: string;
|
|
48
|
+
pause_state: "ready" | "paused" | "budget_exhausted" | "quality_paused";
|
|
49
|
+
pause_reason: string | null;
|
|
50
|
+
paused_at: string | null;
|
|
51
|
+
published_at: string | null;
|
|
52
|
+
retired_at: string | null;
|
|
53
|
+
created_at: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface AiContextRow extends AiConfigRow {
|
|
57
|
+
job_id: string;
|
|
58
|
+
job_state: "pending" | "enqueued" | "processing" | "complete" | "failed" | "dead_letter";
|
|
59
|
+
attempt_count: number;
|
|
60
|
+
report_id: string;
|
|
61
|
+
public_reference: string;
|
|
62
|
+
source: string;
|
|
63
|
+
report_state: ReportState;
|
|
64
|
+
reason_code: string;
|
|
65
|
+
form_version_id: string;
|
|
66
|
+
target_type: string;
|
|
67
|
+
target_reference: string;
|
|
68
|
+
signed_facts_json: string;
|
|
69
|
+
reporter_reference: string | null;
|
|
70
|
+
queue_ai_instructions: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface UsageRow {
|
|
74
|
+
reports_started: number;
|
|
75
|
+
estimated_cost_microusd: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class RetryableAiError extends Error {
|
|
79
|
+
constructor(readonly delaySeconds: number) {
|
|
80
|
+
super("AI processing failed temporarily and was scheduled for retry.");
|
|
81
|
+
this.name = "RetryableAiError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function now(): string {
|
|
86
|
+
return new Date().toISOString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function plusSeconds(timestamp: string, seconds: number): string {
|
|
90
|
+
return new Date(Date.parse(timestamp) + seconds * 1_000).toISOString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function jsonArray(value: string): string[] {
|
|
94
|
+
try {
|
|
95
|
+
const parsed: unknown = JSON.parse(value);
|
|
96
|
+
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : [];
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function jsonObject(value: string): Record<string, unknown> {
|
|
103
|
+
try {
|
|
104
|
+
const parsed: unknown = JSON.parse(value);
|
|
105
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function auditEvent(
|
|
112
|
+
db: D1Database,
|
|
113
|
+
input: { key: string; action: string; targetType: string; targetId: string; actorId: string; details: unknown; createdAt: string },
|
|
114
|
+
): D1PreparedStatement {
|
|
115
|
+
return db.prepare(`
|
|
116
|
+
INSERT OR IGNORE INTO audit_events (
|
|
117
|
+
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
118
|
+
) VALUES (?1, ?2, ?3, 'system', ?4, ?5, ?6, ?7, ?8)
|
|
119
|
+
`).bind(
|
|
120
|
+
crypto.randomUUID(), input.key, input.action, input.actorId, input.targetType, input.targetId,
|
|
121
|
+
canonicalJson(input.details), input.createdAt,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function activeAiConfiguration(db: D1Database, queueId: string, queueVersionId?: string): Promise<AiConfigRow | null> {
|
|
126
|
+
return db.prepare(`
|
|
127
|
+
SELECT * FROM ai_queue_config_versions
|
|
128
|
+
WHERE queue_id = ?1 AND (?2 IS NULL OR queue_version_id = ?2)
|
|
129
|
+
AND published_at IS NOT NULL AND retired_at IS NULL
|
|
130
|
+
ORDER BY version DESC LIMIT 1
|
|
131
|
+
`).bind(queueId, queueVersionId ?? null).first<AiConfigRow>();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function aiConfigurationForRouting(
|
|
135
|
+
db: D1Database,
|
|
136
|
+
queueId: string,
|
|
137
|
+
queueVersionId: string,
|
|
138
|
+
): Promise<{ id: string; queueId: string; mode: AiMode; fallbackQueueId: string } | null> {
|
|
139
|
+
const row = await activeAiConfiguration(db, queueId, queueVersionId);
|
|
140
|
+
if (!row || row.enabled !== 1 || row.mode === "off" || row.pause_state !== "ready") return null;
|
|
141
|
+
return { id: row.id, queueId: row.queue_id, mode: row.mode, fallbackQueueId: row.fallback_queue_id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const protectedContent = /\b(child|minor|underage|csam|sextortion|suicid(?:e|al)|self[- ]?harm|kill|murder|bomb|terror(?:ism|ist)|court order|law enforcement|police|imminent danger|credible threat|nude|intimate image)\b/iu;
|
|
145
|
+
const humanRequest = /\b(human|person|reviewer|manual review|real person)\b/iu;
|
|
146
|
+
|
|
147
|
+
export function requiresHumanFromContent(input: { details: string; messages: string[] }): string | null {
|
|
148
|
+
if (protectedContent.test(input.details)) return "protected_content_detected";
|
|
149
|
+
if (input.messages.some((message) => humanRequest.test(message))) return "reporter_requested_human";
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function aiResponseSchema(allowedOutcomes: string[], policyCodes: string[], evidenceReferences: string[], templateIds: string[]) {
|
|
154
|
+
return {
|
|
155
|
+
type: "object",
|
|
156
|
+
additionalProperties: false,
|
|
157
|
+
properties: {
|
|
158
|
+
outcome: { type: "string", enum: allowedOutcomes },
|
|
159
|
+
policy_code: { anyOf: [{ type: "string", enum: policyCodes }, { type: "null" }] },
|
|
160
|
+
evidence_references: { type: "array", maxItems: 20, items: { type: "string", enum: evidenceReferences.length ? evidenceReferences : ["none"] } },
|
|
161
|
+
summary: { type: "string", maxLength: 500 },
|
|
162
|
+
confidence: { type: "number", minimum: 0, maximum: 1 },
|
|
163
|
+
uncertain: { type: "boolean" },
|
|
164
|
+
message: {
|
|
165
|
+
anyOf: [
|
|
166
|
+
{ type: "null" },
|
|
167
|
+
{
|
|
168
|
+
type: "object", additionalProperties: false,
|
|
169
|
+
properties: {
|
|
170
|
+
template_id: { type: "string", enum: templateIds },
|
|
171
|
+
variables: { type: "object", additionalProperties: { type: "string", maxLength: 160 } },
|
|
172
|
+
},
|
|
173
|
+
required: ["template_id", "variables"],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
action: { type: "null" },
|
|
178
|
+
escalation: { anyOf: [{ type: "string", maxLength: 100 }, { type: "null" }] },
|
|
179
|
+
},
|
|
180
|
+
required: ["outcome", "policy_code", "evidence_references", "summary", "confidence", "uncertain", "message", "action", "escalation"],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function unwrapAiResponse(value: unknown): { output: unknown; inputUnits: number | null; outputUnits: number | null } {
|
|
185
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("ai_response_invalid");
|
|
186
|
+
const response = value as Record<string, unknown>;
|
|
187
|
+
const usage = response.usage && typeof response.usage === "object" && !Array.isArray(response.usage)
|
|
188
|
+
? response.usage as Record<string, unknown> : {};
|
|
189
|
+
let output = response.response;
|
|
190
|
+
if (output === undefined && Array.isArray(response.choices)) {
|
|
191
|
+
const choice = response.choices[0] as Record<string, unknown> | undefined;
|
|
192
|
+
const message = choice?.message && typeof choice.message === "object" ? choice.message as Record<string, unknown> : null;
|
|
193
|
+
output = message?.content;
|
|
194
|
+
}
|
|
195
|
+
if (typeof output === "string") output = JSON.parse(output);
|
|
196
|
+
const encoded = canonicalJson(output);
|
|
197
|
+
if (new TextEncoder().encode(encoded).byteLength > AI_OUTPUT_LIMIT) throw new Error("ai_output_too_large");
|
|
198
|
+
const unit = (candidate: unknown): number | null => typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0 ? candidate : null;
|
|
199
|
+
return {
|
|
200
|
+
output,
|
|
201
|
+
inputUnits: unit(usage.prompt_tokens ?? usage.input_tokens ?? usage.input_units),
|
|
202
|
+
outputUnits: unit(usage.completion_tokens ?? usage.output_tokens ?? usage.output_units),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function costMicrousd(config: AiConfigRow, inputUnits: number | null, outputUnits: number | null): number {
|
|
207
|
+
return Math.max(0, Math.round(
|
|
208
|
+
(inputUnits ?? 0) * config.input_cost_microusd_per_million / 1_000_000
|
|
209
|
+
+ (outputUnits ?? 0) * config.output_cost_microusd_per_million / 1_000_000,
|
|
210
|
+
));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function retryDelay(attempt: number): number {
|
|
214
|
+
return AI_RETRY_DELAYS[Math.min(AI_RETRY_DELAYS.length - 1, Math.max(0, attempt - 1))] ?? 600;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function randomSample(rate: number): boolean {
|
|
218
|
+
if (rate <= 0) return false;
|
|
219
|
+
if (rate >= 1) return true;
|
|
220
|
+
const values = new Uint32Array(1);
|
|
221
|
+
crypto.getRandomValues(values);
|
|
222
|
+
return (values[0] ?? 0) / 0x1_0000_0000 < rate;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function aiContext(db: D1Database, jobId: string): Promise<AiContextRow | null> {
|
|
226
|
+
return db.prepare(`
|
|
227
|
+
SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id,
|
|
228
|
+
r.public_reference, r.source, r.state AS report_state, r.reason_code, r.form_version_id,
|
|
229
|
+
t.target_type, t.target_reference, t.signed_facts_json,
|
|
230
|
+
p.reference AS reporter_reference, qv.ai_instructions AS queue_ai_instructions, c.*
|
|
231
|
+
FROM background_jobs j
|
|
232
|
+
JOIN reports r ON r.id = j.report_id
|
|
233
|
+
JOIN report_targets t ON t.report_id = r.id
|
|
234
|
+
JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
|
|
235
|
+
JOIN ai_queue_config_versions c ON c.id = j.ai_config_version_id
|
|
236
|
+
LEFT JOIN queue_versions qv ON qv.id = c.queue_version_id
|
|
237
|
+
WHERE j.id = ?1 AND j.job_type = 'resume_ai'
|
|
238
|
+
LIMIT 1
|
|
239
|
+
`).bind(jobId).first<AiContextRow>();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function routeToHuman(db: D1Database, context: AiContextRow, reason: string, jobState: "complete" | "dead_letter" = "complete"): Promise<void> {
|
|
243
|
+
const createdAt = now();
|
|
244
|
+
const expectedHumanMode = reason === "ai_shadow_complete" || reason === "ai_assist_complete";
|
|
245
|
+
const transitionsToHuman = context.report_state === "ai_review";
|
|
246
|
+
await db.batch([
|
|
247
|
+
...(transitionsToHuman ? [
|
|
248
|
+
db.prepare(`
|
|
249
|
+
UPDATE reports SET state = 'human_review', queue_id = ?2,
|
|
250
|
+
queue_version_id = (SELECT active_version_id FROM queue_definitions WHERE id = ?2),
|
|
251
|
+
priority = MAX(priority, 60), updated_at = ?3
|
|
252
|
+
WHERE id = ?1 AND state = 'ai_review'
|
|
253
|
+
`).bind(context.report_id, context.fallback_queue_id, createdAt),
|
|
254
|
+
db.prepare(`
|
|
255
|
+
INSERT INTO report_state_history (id, report_id, background_job_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
256
|
+
VALUES (?1, ?2, ?3, 'ai_review', 'human_review', ?4, 'system', 'bounded-ai-v2', ?5)
|
|
257
|
+
`).bind(crypto.randomUUID(), context.report_id, context.job_id, reason, createdAt),
|
|
258
|
+
] : []),
|
|
259
|
+
db.prepare(`UPDATE background_jobs SET state = ?2, last_error_code = ?3, updated_at = ?4 WHERE id = ?1`)
|
|
260
|
+
.bind(context.job_id, jobState, reason.slice(0, 100), createdAt),
|
|
261
|
+
...(!expectedHumanMode && transitionsToHuman ? [usageStatement(db, context.id, { escalated: 1 }, createdAt)] : []),
|
|
262
|
+
auditEvent(db, {
|
|
263
|
+
key: `ai-fallback:${context.job_id}:${reason}`,
|
|
264
|
+
action: "ai.report_escalated",
|
|
265
|
+
actorId: "bounded-ai-v2",
|
|
266
|
+
targetType: "report",
|
|
267
|
+
targetId: context.report_id,
|
|
268
|
+
details: { aiConfigVersionId: context.id, queueVersionId: context.queue_version_id, reason, fallbackQueueId: context.fallback_queue_id, reportAlreadyHuman: !transitionsToHuman },
|
|
269
|
+
createdAt,
|
|
270
|
+
}),
|
|
271
|
+
]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function usageStatement(
|
|
275
|
+
db: D1Database,
|
|
276
|
+
configId: string,
|
|
277
|
+
fields: {
|
|
278
|
+
started?: number; resolved?: number; escalated?: number; failures?: number; cost?: number;
|
|
279
|
+
eligible?: number; waiting?: number; humanRequests?: number; appeals?: number; reversals?: number;
|
|
280
|
+
invalidOutputs?: number; providerErrors?: number; timeouts?: number;
|
|
281
|
+
},
|
|
282
|
+
at: string,
|
|
283
|
+
): D1PreparedStatement {
|
|
284
|
+
return db.prepare(`
|
|
285
|
+
INSERT INTO ai_usage_daily (
|
|
286
|
+
usage_date, config_version_id, reports_started, reports_resolved, reports_escalated,
|
|
287
|
+
failures, estimated_cost_microusd, updated_at, eligible_reports, waiting_for_reporter,
|
|
288
|
+
human_review_requests, appeals_filed, appeal_reversals, invalid_outputs, provider_errors, timeouts
|
|
289
|
+
) VALUES (substr(?1, 1, 10), ?2, ?3, ?4, ?5, ?6, ?7, ?1, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
|
290
|
+
ON CONFLICT(usage_date, config_version_id) DO UPDATE SET
|
|
291
|
+
reports_started = reports_started + excluded.reports_started,
|
|
292
|
+
reports_resolved = reports_resolved + excluded.reports_resolved,
|
|
293
|
+
reports_escalated = reports_escalated + excluded.reports_escalated,
|
|
294
|
+
failures = failures + excluded.failures,
|
|
295
|
+
estimated_cost_microusd = estimated_cost_microusd + excluded.estimated_cost_microusd,
|
|
296
|
+
eligible_reports = eligible_reports + excluded.eligible_reports,
|
|
297
|
+
waiting_for_reporter = waiting_for_reporter + excluded.waiting_for_reporter,
|
|
298
|
+
human_review_requests = human_review_requests + excluded.human_review_requests,
|
|
299
|
+
appeals_filed = appeals_filed + excluded.appeals_filed,
|
|
300
|
+
appeal_reversals = appeal_reversals + excluded.appeal_reversals,
|
|
301
|
+
invalid_outputs = invalid_outputs + excluded.invalid_outputs,
|
|
302
|
+
provider_errors = provider_errors + excluded.provider_errors,
|
|
303
|
+
timeouts = timeouts + excluded.timeouts,
|
|
304
|
+
updated_at = excluded.updated_at
|
|
305
|
+
`).bind(
|
|
306
|
+
at, configId, fields.started ?? 0, fields.resolved ?? 0, fields.escalated ?? 0,
|
|
307
|
+
fields.failures ?? 0, fields.cost ?? 0, fields.eligible ?? 0, fields.waiting ?? 0,
|
|
308
|
+
fields.humanRequests ?? 0, fields.appeals ?? 0, fields.reversals ?? 0,
|
|
309
|
+
fields.invalidOutputs ?? 0, fields.providerErrors ?? 0, fields.timeouts ?? 0,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function renderTemplate(db: D1Database, templateId: string, variables: Record<string, string>): Promise<string> {
|
|
314
|
+
const template = await db.prepare(`
|
|
315
|
+
SELECT body_template, variables_schema_json FROM message_template_versions
|
|
316
|
+
WHERE id = ?1 AND automation_allowed = 1 AND audience = 'reporter' AND published_at IS NOT NULL LIMIT 1
|
|
317
|
+
`).bind(templateId).first<{ body_template: string; variables_schema_json: string }>();
|
|
318
|
+
if (!template) throw new Error("ai_template_unavailable");
|
|
319
|
+
const schema = jsonObject(template.variables_schema_json);
|
|
320
|
+
if (Object.keys(variables).some((key) => !(key in schema)) || Object.keys(schema).some((key) => !(key in variables))) {
|
|
321
|
+
throw new Error("ai_template_variables_invalid");
|
|
322
|
+
}
|
|
323
|
+
let body = template.body_template;
|
|
324
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
325
|
+
const rule = schema[key] as Record<string, unknown>;
|
|
326
|
+
const minimum = typeof rule?.minLength === "number" ? rule.minLength : 1;
|
|
327
|
+
const maximum = typeof rule?.maxLength === "number" ? rule.maxLength : 160;
|
|
328
|
+
if (value.length < minimum || value.length > maximum) throw new Error("ai_template_variables_invalid");
|
|
329
|
+
body = body.replaceAll(`{{${key}}}`, value);
|
|
330
|
+
}
|
|
331
|
+
if (/\{\{[^}]+\}\}/u.test(body) || body.length > 1_000) throw new Error("ai_template_render_invalid");
|
|
332
|
+
return body;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function saveAiResult(
|
|
336
|
+
db: D1Database,
|
|
337
|
+
context: AiContextRow,
|
|
338
|
+
runId: string,
|
|
339
|
+
result: ValidatedAiResult | null,
|
|
340
|
+
errors: string[],
|
|
341
|
+
output: unknown,
|
|
342
|
+
status: "succeeded" | "invalid" | "escalated",
|
|
343
|
+
units: { input: number | null; output: number | null; cost: number },
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
const finishedAt = now();
|
|
346
|
+
await db.batch([
|
|
347
|
+
db.prepare(`
|
|
348
|
+
UPDATE ai_runs SET status = ?2, output_json = ?3, error_code = ?4, finished_at = ?5,
|
|
349
|
+
input_units = ?6, output_units = ?7, estimated_cost_microusd = ?8 WHERE id = ?1
|
|
350
|
+
`).bind(runId, status, canonicalJson(output), errors[0] ?? null, finishedAt, units.input, units.output, units.cost),
|
|
351
|
+
db.prepare(`
|
|
352
|
+
INSERT OR REPLACE INTO ai_results (
|
|
353
|
+
id, ai_run_id, outcome, policy_code, evidence_references_json, summary, confidence, uncertain,
|
|
354
|
+
message_template_id, message_variables_json, action_code, escalation_code,
|
|
355
|
+
validation_state, validation_errors_json, created_at
|
|
356
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
|
357
|
+
`).bind(
|
|
358
|
+
crypto.randomUUID(), runId, result?.outcome ?? "invalid", result?.policyCode ?? null,
|
|
359
|
+
canonicalJson(result?.evidenceReferences ?? []), result?.summary ?? "Invalid structured output",
|
|
360
|
+
result?.confidence ?? null, result?.uncertain ? 1 : 0, result?.message?.templateId ?? null,
|
|
361
|
+
result?.message ? canonicalJson(result.message.variables) : null, result?.actionCode ?? null,
|
|
362
|
+
result?.escalationCode ?? null, errors.length ? "invalid" : "valid", canonicalJson(errors), finishedAt,
|
|
363
|
+
),
|
|
364
|
+
usageStatement(db, context.id, { cost: units.cost }, finishedAt),
|
|
365
|
+
]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function terminalAiFailure(db: D1Database, context: AiContextRow, runId: string, errorCode: string): Promise<void> {
|
|
369
|
+
const finishedAt = now();
|
|
370
|
+
const fingerprint = `ai-runtime:${context.job_id}`;
|
|
371
|
+
await db.batch([
|
|
372
|
+
db.prepare(`UPDATE ai_runs SET status = 'failed', error_code = ?2, finished_at = ?3 WHERE id = ?1`).bind(runId, errorCode, finishedAt),
|
|
373
|
+
db.prepare(`
|
|
374
|
+
UPDATE reports SET state = 'human_review', queue_id = ?2,
|
|
375
|
+
queue_version_id = (SELECT active_version_id FROM queue_definitions WHERE id = ?2), priority = MAX(priority, 60),
|
|
376
|
+
routing_error_code = ?3, updated_at = ?4 WHERE id = ?1 AND state = 'ai_review'
|
|
377
|
+
`).bind(context.report_id, context.fallback_queue_id, errorCode, finishedAt),
|
|
378
|
+
db.prepare(`UPDATE background_jobs SET state = 'dead_letter', last_error_code = ?2, updated_at = ?3 WHERE id = ?1`)
|
|
379
|
+
.bind(context.job_id, errorCode, finishedAt),
|
|
380
|
+
db.prepare(`
|
|
381
|
+
INSERT INTO dead_letter_items (
|
|
382
|
+
id, fingerprint, source_queue, source_message_id, job_type, job_id, report_id,
|
|
383
|
+
state, error_code, details_json, first_seen_at, last_seen_at
|
|
384
|
+
) VALUES (?1, ?2, 'ai-runtime', ?3, 'process_ai_report', ?3, ?4, 'open', ?5, ?6, ?7, ?7)
|
|
385
|
+
ON CONFLICT(fingerprint) DO UPDATE SET occurrence_count = occurrence_count + 1,
|
|
386
|
+
state = 'open', error_code = excluded.error_code, details_json = excluded.details_json,
|
|
387
|
+
last_seen_at = excluded.last_seen_at, resolved_at = NULL
|
|
388
|
+
`).bind(
|
|
389
|
+
crypto.randomUUID(), fingerprint, context.job_id, context.report_id, errorCode,
|
|
390
|
+
canonicalJson({ version: 1, configVersionId: context.id, fallbackQueueId: context.fallback_queue_id }), finishedAt,
|
|
391
|
+
),
|
|
392
|
+
db.prepare(`
|
|
393
|
+
INSERT INTO report_state_history (id, report_id, background_job_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
394
|
+
SELECT ?1, ?2, ?3, 'ai_review', 'human_review', ?4, 'system', 'bounded-ai-v2', ?5
|
|
395
|
+
WHERE ?6 = 'ai_review'
|
|
396
|
+
`).bind(crypto.randomUUID(), context.report_id, context.job_id, errorCode, finishedAt, context.report_state),
|
|
397
|
+
usageStatement(db, context.id, {
|
|
398
|
+
failures: 1,
|
|
399
|
+
escalated: 1,
|
|
400
|
+
...(errorCode === "ai_timeout" ? { timeouts: 1 } : { providerErrors: 1 }),
|
|
401
|
+
}, finishedAt),
|
|
402
|
+
auditEvent(db, {
|
|
403
|
+
key: `ai-terminal-failure:${context.job_id}`,
|
|
404
|
+
action: "ai.processing_failed_to_human",
|
|
405
|
+
actorId: "bounded-ai-v2",
|
|
406
|
+
targetType: "report",
|
|
407
|
+
targetId: context.report_id,
|
|
408
|
+
details: { aiConfigVersionId: context.id, errorCode, fallbackQueueId: context.fallback_queue_id },
|
|
409
|
+
createdAt: finishedAt,
|
|
410
|
+
}),
|
|
411
|
+
]);
|
|
412
|
+
await evaluateAiFailurePause(db, context.id);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function providerFailure(db: D1Database, context: AiContextRow, runId: string, errorCode: string): Promise<never | void> {
|
|
416
|
+
if (context.attempt_count >= AI_MAX_ATTEMPTS) return terminalAiFailure(db, context, runId, errorCode);
|
|
417
|
+
const failedAt = now();
|
|
418
|
+
const delay = retryDelay(context.attempt_count);
|
|
419
|
+
await db.batch([
|
|
420
|
+
db.prepare(`UPDATE ai_runs SET status = 'failed', error_code = ?2, finished_at = ?3 WHERE id = ?1`).bind(runId, errorCode, failedAt),
|
|
421
|
+
db.prepare(`
|
|
422
|
+
UPDATE background_jobs SET state = 'failed', available_at = ?2, last_error_code = ?3, updated_at = ?4
|
|
423
|
+
WHERE id = ?1
|
|
424
|
+
`).bind(context.job_id, plusSeconds(failedAt, delay), errorCode, failedAt),
|
|
425
|
+
usageStatement(db, context.id, {
|
|
426
|
+
failures: 1,
|
|
427
|
+
...(errorCode === "ai_timeout" ? { timeouts: 1 } : { providerErrors: 1 }),
|
|
428
|
+
}, failedAt),
|
|
429
|
+
]);
|
|
430
|
+
throw new RetryableAiError(delay);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface AiProcessOptions {
|
|
434
|
+
notificationEnabled: boolean;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function processAiJob(
|
|
438
|
+
db: D1Database,
|
|
439
|
+
ai: AiBinding | undefined,
|
|
440
|
+
job: AiJob,
|
|
441
|
+
options: AiProcessOptions,
|
|
442
|
+
): Promise<DeliveryJob[]> {
|
|
443
|
+
const claimedAt = now();
|
|
444
|
+
const claim = await db.prepare(`
|
|
445
|
+
UPDATE background_jobs SET state = 'processing', attempt_count = attempt_count + 1, updated_at = ?2
|
|
446
|
+
WHERE id = ?1 AND job_type = 'resume_ai' AND available_at <= ?2
|
|
447
|
+
AND (state IN ('pending', 'enqueued', 'failed') OR (state = 'processing' AND updated_at <= ?3))
|
|
448
|
+
`).bind(job.jobId, claimedAt, plusSeconds(claimedAt, -60)).run();
|
|
449
|
+
if ((claim.meta.changes ?? 0) < 1) return [];
|
|
450
|
+
const context = await aiContext(db, job.jobId);
|
|
451
|
+
if (!context) throw new Error("AI job context does not exist.");
|
|
452
|
+
const expectedReportState = context.mode === "shadow" ? "human_review" : "ai_review";
|
|
453
|
+
if (context.report_state !== expectedReportState) {
|
|
454
|
+
await db.prepare(`UPDATE background_jobs SET state = 'complete', updated_at = ?2 WHERE id = ?1`).bind(job.jobId, claimedAt).run();
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
if (context.attempt_count === 1) await db.batch([usageStatement(db, context.id, { eligible: 1 }, claimedAt)]);
|
|
458
|
+
if (context.enabled !== 1 || context.mode === "off" || context.pause_state !== "ready" || !ai) {
|
|
459
|
+
await routeToHuman(db, context, !ai ? "ai_binding_unavailable" : "ai_configuration_not_active");
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
const usage = await db.prepare(`
|
|
463
|
+
SELECT reports_started, estimated_cost_microusd FROM ai_usage_daily
|
|
464
|
+
WHERE usage_date = substr(?1, 1, 10) AND config_version_id = ?2 LIMIT 1
|
|
465
|
+
`).bind(claimedAt, context.id).first<UsageRow>();
|
|
466
|
+
if ((usage?.reports_started ?? 0) >= context.daily_report_budget
|
|
467
|
+
|| (usage?.estimated_cost_microusd ?? 0) >= context.daily_cost_budget_microusd) {
|
|
468
|
+
await db.batch([
|
|
469
|
+
db.prepare(`
|
|
470
|
+
UPDATE ai_queue_config_versions SET pause_state = 'budget_exhausted',
|
|
471
|
+
pause_reason = 'daily_budget_exhausted', paused_at = ?2 WHERE id = ?1
|
|
472
|
+
`).bind(context.id, claimedAt),
|
|
473
|
+
auditEvent(db, {
|
|
474
|
+
key: `ai-budget:${context.id}:${claimedAt.slice(0, 10)}`,
|
|
475
|
+
action: "ai.queue_budget_exhausted",
|
|
476
|
+
actorId: "bounded-ai-v1",
|
|
477
|
+
targetType: "ai_queue_configuration",
|
|
478
|
+
targetId: context.id,
|
|
479
|
+
details: { usageDate: claimedAt.slice(0, 10), fallbackQueueId: context.fallback_queue_id },
|
|
480
|
+
createdAt: claimedAt,
|
|
481
|
+
}),
|
|
482
|
+
]);
|
|
483
|
+
await routeToHuman(db, context, "ai_budget_exhausted");
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const answersResult = await db.prepare(`SELECT field_key, value_json FROM report_answers WHERE report_id = ?1 ORDER BY field_key`)
|
|
488
|
+
.bind(context.report_id).all<{ field_key: string; value_json: string }>();
|
|
489
|
+
const evidenceResult = await db.prepare(`SELECT reference FROM evidence_references WHERE report_id = ?1 ORDER BY created_at, id LIMIT 20`)
|
|
490
|
+
.bind(context.report_id).all<{ reference: string }>();
|
|
491
|
+
const messagesResult = await db.prepare(`
|
|
492
|
+
SELECT body FROM case_messages WHERE report_id = ?1 AND audience = 'reporter' AND direction = 'inbound'
|
|
493
|
+
ORDER BY created_at, id LIMIT 20
|
|
494
|
+
`).bind(context.report_id).all<{ body: string }>();
|
|
495
|
+
const findingsResult = await db.prepare(`
|
|
496
|
+
SELECT f.finding_type AS findingType, f.data_json AS dataJson
|
|
497
|
+
FROM findings f
|
|
498
|
+
WHERE f.report_id = ?1 AND f.status = 'available'
|
|
499
|
+
AND (f.expires_at IS NULL OR f.expires_at > ?2)
|
|
500
|
+
AND NOT EXISTS (
|
|
501
|
+
SELECT 1 FROM findings newer
|
|
502
|
+
WHERE newer.report_id = f.report_id AND newer.finding_type = f.finding_type
|
|
503
|
+
AND newer.status = 'available' AND (newer.expires_at IS NULL OR newer.expires_at > ?2)
|
|
504
|
+
AND (newer.created_at > f.created_at OR (newer.created_at = f.created_at AND newer.id > f.id))
|
|
505
|
+
)
|
|
506
|
+
ORDER BY f.finding_type, f.created_at DESC, f.id DESC
|
|
507
|
+
`).bind(context.report_id, claimedAt).all<{ findingType: string; dataJson: string }>();
|
|
508
|
+
const answers = Object.fromEntries(answersResult.results.map((row) => {
|
|
509
|
+
try { return [row.field_key, JSON.parse(row.value_json)]; } catch { return [row.field_key, null]; }
|
|
510
|
+
}));
|
|
511
|
+
const details = typeof answers.details === "string" ? answers.details : "";
|
|
512
|
+
const forcedHumanReason = requiresHumanFromContent({ details, messages: messagesResult.results.map((row) => row.body) });
|
|
513
|
+
if (forcedHumanReason) {
|
|
514
|
+
await routeToHuman(db, context, forcedHumanReason);
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
const trustedFacts = jsonObject(context.signed_facts_json);
|
|
518
|
+
if (["protected_category", "minor_involved", "high_impact", "identity_missing", "evidence_unavailable", "trusted_facts_conflict"]
|
|
519
|
+
.some((key) => trustedFacts[key] === true)) {
|
|
520
|
+
await routeToHuman(db, context, "protected_trusted_fact");
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
const policy = await db.prepare(`SELECT rules_json FROM policy_versions WHERE id = ?1 AND published_at IS NOT NULL LIMIT 1`)
|
|
524
|
+
.bind(context.policy_version_id).first<{ rules_json: string }>();
|
|
525
|
+
const policyRules = policy ? (() => { try { const value: unknown = JSON.parse(policy.rules_json); return Array.isArray(value) ? value : []; } catch { return []; } })() : [];
|
|
526
|
+
const policyCodes = policyRules.map((rule) => rule && typeof rule === "object" && !Array.isArray(rule) ? (rule as Record<string, unknown>).code : null)
|
|
527
|
+
.filter((code): code is string => typeof code === "string");
|
|
528
|
+
if (!policyCodes.length) {
|
|
529
|
+
await routeToHuman(db, context, "ai_policy_unavailable");
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
const allowedFields = new Set(jsonArray(context.allowed_fields_json));
|
|
533
|
+
const evidenceReferences = evidenceResult.results.map((row) => row.reference);
|
|
534
|
+
const enrichments = Object.fromEntries(findingsResult.results.map((row) => [row.findingType, jsonObject(row.dataJson)]));
|
|
535
|
+
const modelCase = {
|
|
536
|
+
allegation: context.reason_code,
|
|
537
|
+
...(allowedFields.has("target.type") ? { target_type: context.target_type } : {}),
|
|
538
|
+
...(allowedFields.has("answers.details") ? { details } : {}),
|
|
539
|
+
...(allowedFields.has("evidence.references") ? { evidence_references: evidenceReferences } : {}),
|
|
540
|
+
...(allowedFields.has("enrichments") ? { enrichments } : {}),
|
|
541
|
+
};
|
|
542
|
+
const inputReferences = {
|
|
543
|
+
fields: Object.keys(modelCase),
|
|
544
|
+
evidenceReferences: allowedFields.has("evidence.references") ? evidenceReferences : [],
|
|
545
|
+
messageCount: messagesResult.results.length,
|
|
546
|
+
};
|
|
547
|
+
const runId = crypto.randomUUID();
|
|
548
|
+
await db.batch([
|
|
549
|
+
db.prepare(`
|
|
550
|
+
INSERT INTO ai_runs (
|
|
551
|
+
id, report_id, background_job_id, config_version_id, mode, provider, model,
|
|
552
|
+
prompt_version, policy_version_id, status, input_references_json, started_at
|
|
553
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'running', ?10, ?11)
|
|
554
|
+
ON CONFLICT(background_job_id) DO UPDATE SET status = 'running', error_code = NULL,
|
|
555
|
+
output_json = NULL, started_at = excluded.started_at, finished_at = NULL
|
|
556
|
+
`).bind(
|
|
557
|
+
runId, context.report_id, context.job_id, context.id, context.mode, context.provider, context.model,
|
|
558
|
+
context.prompt_version, context.policy_version_id, canonicalJson(inputReferences), claimedAt,
|
|
559
|
+
),
|
|
560
|
+
...(context.attempt_count === 1 ? [usageStatement(db, context.id, { started: 1 }, claimedAt)] : []),
|
|
561
|
+
]);
|
|
562
|
+
const storedRun = await db.prepare(`SELECT id FROM ai_runs WHERE background_job_id = ?1 LIMIT 1`)
|
|
563
|
+
.bind(context.job_id).first<{ id: string }>();
|
|
564
|
+
if (!storedRun) throw new Error("AI run was not created.");
|
|
565
|
+
|
|
566
|
+
const allowedOutcomes = jsonArray(context.allowed_outcomes_json);
|
|
567
|
+
const allowedTemplates = jsonArray(context.allowed_template_ids_json);
|
|
568
|
+
let providerResult: unknown;
|
|
569
|
+
try {
|
|
570
|
+
providerResult = await Promise.race([
|
|
571
|
+
ai.run(context.model, {
|
|
572
|
+
messages: [
|
|
573
|
+
{
|
|
574
|
+
role: "system",
|
|
575
|
+
content: `You are a bounded safety workflow. Use only the supplied case, enrichment data, and policy codes. Never infer hidden facts. Escalate if uncertain. Customer queue instructions: ${context.queue_ai_instructions ?? "Escalate to a human."} Return only the requested structured object. Configuration version: ${context.id}.`,
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
role: "user",
|
|
579
|
+
content: canonicalJson({ case: modelCase, policy_codes: policyCodes, allowed_outcomes: allowedOutcomes, approved_templates: allowedTemplates }),
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
response_format: {
|
|
583
|
+
type: "json_schema",
|
|
584
|
+
json_schema: aiResponseSchema(allowedOutcomes, policyCodes, evidenceReferences, allowedTemplates),
|
|
585
|
+
},
|
|
586
|
+
}),
|
|
587
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("ai_timeout")), AI_TIMEOUT_MS)),
|
|
588
|
+
]);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
const errorCode = error instanceof Error && error.message === "ai_timeout" ? "ai_timeout" : "ai_provider_error";
|
|
591
|
+
await providerFailure(db, context, storedRun.id, errorCode);
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
let unwrapped: ReturnType<typeof unwrapAiResponse>;
|
|
595
|
+
try {
|
|
596
|
+
unwrapped = unwrapAiResponse(providerResult);
|
|
597
|
+
} catch {
|
|
598
|
+
await providerFailure(db, context, storedRun.id, "ai_response_invalid");
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
const estimatedCost = costMicrousd(context, unwrapped.inputUnits, unwrapped.outputUnits);
|
|
602
|
+
const validation = validateAiResult(unwrapped.output, {
|
|
603
|
+
allowedOutcomes: new Set(allowedOutcomes),
|
|
604
|
+
allowedPolicyCodes: new Set(policyCodes),
|
|
605
|
+
allowedEvidenceReferences: new Set(evidenceReferences),
|
|
606
|
+
allowedTemplateIds: new Set(allowedTemplates),
|
|
607
|
+
allowedActionCodes: new Set(jsonArray(context.allowed_action_codes_json)),
|
|
608
|
+
confidenceThreshold: context.confidence_threshold,
|
|
609
|
+
});
|
|
610
|
+
if (!validation.result) {
|
|
611
|
+
await saveAiResult(db, context, storedRun.id, null, validation.errors, unwrapped.output, "invalid", {
|
|
612
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
613
|
+
});
|
|
614
|
+
await db.batch([usageStatement(db, context.id, { failures: 1, invalidOutputs: 1 }, now())]);
|
|
615
|
+
await evaluateAiFailurePause(db, context.id);
|
|
616
|
+
await routeToHuman(db, context, "ai_output_invalid");
|
|
617
|
+
return [];
|
|
618
|
+
}
|
|
619
|
+
const result = validation.result;
|
|
620
|
+
if (context.mode === "shadow" || context.mode === "assist") {
|
|
621
|
+
await saveAiResult(db, context, storedRun.id, result, [], unwrapped.output, "succeeded", {
|
|
622
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
623
|
+
});
|
|
624
|
+
if (context.mode === "shadow") {
|
|
625
|
+
await db.prepare(`
|
|
626
|
+
INSERT OR IGNORE INTO ai_quality_samples (id, ai_run_id, report_id, sample_reason, state, selected_at)
|
|
627
|
+
VALUES (?1, ?2, ?3, 'shadow_comparison', 'pending', ?4)
|
|
628
|
+
`).bind(crypto.randomUUID(), storedRun.id, context.report_id, now()).run();
|
|
629
|
+
}
|
|
630
|
+
await routeToHuman(db, context, context.mode === "shadow" ? "ai_shadow_complete" : "ai_assist_complete");
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
if (result.uncertain || result.outcome === "escalate_to_human" || result.actionCode) {
|
|
634
|
+
await saveAiResult(db, context, storedRun.id, result, [], unwrapped.output, "escalated", {
|
|
635
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
636
|
+
});
|
|
637
|
+
await routeToHuman(db, context, result.actionCode ? "ai_action_requires_human" : result.escalationCode ?? "ai_uncertain");
|
|
638
|
+
return [];
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const finishedAt = now();
|
|
642
|
+
const decisionId = crypto.randomUUID();
|
|
643
|
+
const messageId = crypto.randomUUID();
|
|
644
|
+
const noticeId = crypto.randomUUID();
|
|
645
|
+
const notificationId = crypto.randomUUID();
|
|
646
|
+
let templateId: string;
|
|
647
|
+
let variables: Record<string, string>;
|
|
648
|
+
let nextState: ReportState;
|
|
649
|
+
if (result.outcome === "insufficient_information") {
|
|
650
|
+
const followUps = await db.prepare(`
|
|
651
|
+
SELECT COUNT(*) AS count FROM case_messages
|
|
652
|
+
WHERE report_id = ?1 AND sender_type = 'ai' AND template_version_id = 'ai-more-context-v1'
|
|
653
|
+
`).bind(context.report_id).first<{ count: number }>();
|
|
654
|
+
if (Number(followUps?.count ?? 0) >= context.max_follow_up_turns) {
|
|
655
|
+
await saveAiResult(db, context, storedRun.id, result, [], unwrapped.output, "escalated", {
|
|
656
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
657
|
+
});
|
|
658
|
+
await routeToHuman(db, context, "ai_follow_up_limit_reached");
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
if (!result.message || result.message.templateId !== "ai-more-context-v1") {
|
|
662
|
+
await saveAiResult(db, context, storedRun.id, result, ["follow_up_template_invalid"], unwrapped.output, "invalid", {
|
|
663
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
664
|
+
});
|
|
665
|
+
await db.batch([usageStatement(db, context.id, { failures: 1, invalidOutputs: 1 }, now())]);
|
|
666
|
+
await evaluateAiFailurePause(db, context.id);
|
|
667
|
+
await routeToHuman(db, context, "ai_output_invalid");
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
templateId = result.message.templateId;
|
|
671
|
+
variables = result.message.variables;
|
|
672
|
+
nextState = "awaiting_reporter";
|
|
673
|
+
} else if (result.outcome === "no_violation_found") {
|
|
674
|
+
templateId = "ai-no-violation-v1";
|
|
675
|
+
variables = { policy_code: result.policyCode ?? "the selected policy" };
|
|
676
|
+
nextState = "resolved_by_ai";
|
|
677
|
+
} else if (result.outcome === "out_of_scope") {
|
|
678
|
+
templateId = "ai-out-of-scope-v1";
|
|
679
|
+
variables = { policy_code: result.policyCode ?? "the selected policy" };
|
|
680
|
+
nextState = "resolved_by_ai";
|
|
681
|
+
} else {
|
|
682
|
+
await saveAiResult(db, context, storedRun.id, result, ["autonomous_outcome_requires_human"], unwrapped.output, "invalid", {
|
|
683
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
684
|
+
});
|
|
685
|
+
await db.batch([usageStatement(db, context.id, { failures: 1, invalidOutputs: 1 }, now())]);
|
|
686
|
+
await evaluateAiFailurePause(db, context.id);
|
|
687
|
+
await routeToHuman(db, context, "ai_output_invalid");
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
let renderedBody: string;
|
|
691
|
+
try {
|
|
692
|
+
renderedBody = await renderTemplate(db, templateId, variables);
|
|
693
|
+
} catch {
|
|
694
|
+
await saveAiResult(db, context, storedRun.id, result, ["template_render_failed"], unwrapped.output, "invalid", {
|
|
695
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
696
|
+
});
|
|
697
|
+
await db.batch([usageStatement(db, context.id, { failures: 1, invalidOutputs: 1 }, now())]);
|
|
698
|
+
await evaluateAiFailurePause(db, context.id);
|
|
699
|
+
await routeToHuman(db, context, "ai_template_failed");
|
|
700
|
+
return [];
|
|
701
|
+
}
|
|
702
|
+
await saveAiResult(db, context, storedRun.id, result, [], unwrapped.output, "succeeded", {
|
|
703
|
+
input: unwrapped.inputUnits, output: unwrapped.outputUnits, cost: estimatedCost,
|
|
704
|
+
});
|
|
705
|
+
const statements: D1PreparedStatement[] = [
|
|
706
|
+
db.prepare(`
|
|
707
|
+
UPDATE reports SET state = ?2, queue_id = ?3, resolved_at = ?4, updated_at = ?5 WHERE id = ?1 AND state = 'ai_review'
|
|
708
|
+
`).bind(
|
|
709
|
+
context.report_id, nextState, nextState === "awaiting_reporter" ? context.queue_id : null,
|
|
710
|
+
nextState === "resolved_by_ai" ? finishedAt : null, finishedAt,
|
|
711
|
+
),
|
|
712
|
+
db.prepare(`
|
|
713
|
+
INSERT INTO report_state_history (id, report_id, background_job_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
714
|
+
VALUES (?1, ?2, ?3, 'ai_review', ?4, ?5, 'ai', ?6, ?7)
|
|
715
|
+
`).bind(crypto.randomUUID(), context.report_id, context.job_id, nextState, `ai_${result.outcome}`, context.id, finishedAt),
|
|
716
|
+
db.prepare(`UPDATE background_jobs SET state = 'complete', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(context.job_id, finishedAt),
|
|
717
|
+
db.prepare(`
|
|
718
|
+
INSERT INTO case_messages (
|
|
719
|
+
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
720
|
+
automated, delivery_state, created_at, idempotency_key
|
|
721
|
+
) VALUES (?1, ?2, 'reporter', 'outbound', 'ai', ?3, ?4, ?5, 1, ?6, ?7, ?8)
|
|
722
|
+
`).bind(
|
|
723
|
+
messageId, context.report_id, context.id, renderedBody, templateId,
|
|
724
|
+
options.notificationEnabled ? "queued" : "suppressed", finishedAt, `ai-message:${storedRun.id}`,
|
|
725
|
+
),
|
|
726
|
+
usageStatement(db, context.id, nextState === "resolved_by_ai" ? { resolved: 1 } : { waiting: 1 }, finishedAt),
|
|
727
|
+
auditEvent(db, {
|
|
728
|
+
key: `ai-outcome:${storedRun.id}`,
|
|
729
|
+
action: nextState === "resolved_by_ai" ? "ai.report_resolved" : "ai.reporter_information_requested",
|
|
730
|
+
actorId: context.id,
|
|
731
|
+
targetType: "report",
|
|
732
|
+
targetId: context.report_id,
|
|
733
|
+
details: { aiRunId: storedRun.id, outcome: result.outcome, policyCode: result.policyCode, templateId },
|
|
734
|
+
createdAt: finishedAt,
|
|
735
|
+
}),
|
|
736
|
+
];
|
|
737
|
+
if (nextState === "resolved_by_ai") {
|
|
738
|
+
statements.push(db.prepare(`
|
|
739
|
+
INSERT INTO decisions (
|
|
740
|
+
id, report_id, decision_code, policy_version_id, policy_code, rationale,
|
|
741
|
+
evidence_references_json, maker_type, maker_id, queue_version_id, ai_run_id,
|
|
742
|
+
proposed_action_code, created_at, idempotency_key
|
|
743
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'ai', ?8, ?9, ?10, NULL, ?11, ?12)
|
|
744
|
+
`).bind(
|
|
745
|
+
decisionId, context.report_id, result.outcome, context.policy_version_id, result.policyCode,
|
|
746
|
+
result.summary, canonicalJson(result.evidenceReferences), context.id, context.queue_version_id, storedRun.id,
|
|
747
|
+
finishedAt, `ai-decision:${storedRun.id}`,
|
|
748
|
+
));
|
|
749
|
+
if (randomSample(context.quality_sample_rate)) {
|
|
750
|
+
statements.push(db.prepare(`
|
|
751
|
+
INSERT OR IGNORE INTO ai_quality_samples (id, ai_run_id, report_id, sample_reason, state, selected_at)
|
|
752
|
+
VALUES (?1, ?2, ?3, 'blind_random_sample', 'pending', ?4)
|
|
753
|
+
`).bind(crypto.randomUUID(), storedRun.id, context.report_id, finishedAt));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
statements.push(db.prepare(`
|
|
757
|
+
INSERT INTO notices (
|
|
758
|
+
id, report_id, decision_id, audience, template_version_id, rendered_body,
|
|
759
|
+
delivery_state, created_at, updated_at, idempotency_key, message_id
|
|
760
|
+
) VALUES (?1, ?2, ?3, 'reporter', ?4, ?5, ?6, ?7, ?7, ?8, ?9)
|
|
761
|
+
`).bind(
|
|
762
|
+
noticeId, context.report_id, nextState === "resolved_by_ai" ? decisionId : null, templateId, renderedBody,
|
|
763
|
+
options.notificationEnabled ? "queued" : "suppressed", finishedAt, `ai-notice:${storedRun.id}`, messageId,
|
|
764
|
+
));
|
|
765
|
+
const jobs: DeliveryJob[] = [];
|
|
766
|
+
if (options.notificationEnabled) {
|
|
767
|
+
statements.push(db.prepare(`
|
|
768
|
+
INSERT INTO notification_outbox (
|
|
769
|
+
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
770
|
+
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
771
|
+
`).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
|
|
772
|
+
version: 1,
|
|
773
|
+
event: nextState === "resolved_by_ai" ? "report.outcome.available" : "report.message.available",
|
|
774
|
+
notification_id: notificationId,
|
|
775
|
+
report_reference: context.public_reference,
|
|
776
|
+
...(context.reporter_reference ? { recipient_reference: context.reporter_reference } : {}),
|
|
777
|
+
audience: "reporter",
|
|
778
|
+
message: renderedBody,
|
|
779
|
+
}), finishedAt));
|
|
780
|
+
jobs.push({ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId });
|
|
781
|
+
}
|
|
782
|
+
await db.batch(statements);
|
|
783
|
+
return jobs;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function pendingAiJobs(db: D1Database, limit = 50): Promise<AiJob[]> {
|
|
787
|
+
const at = now();
|
|
788
|
+
const result = await db.prepare(`
|
|
789
|
+
SELECT id, report_id FROM background_jobs
|
|
790
|
+
WHERE job_type = 'resume_ai' AND available_at <= ?1
|
|
791
|
+
AND (state IN ('pending', 'failed') OR (state = 'processing' AND updated_at <= ?2))
|
|
792
|
+
ORDER BY available_at, id LIMIT ?3
|
|
793
|
+
`).bind(at, plusSeconds(at, -60), Math.max(1, Math.min(100, limit))).all<{ id: string; report_id: string }>();
|
|
794
|
+
return result.results.map((row) => ({ version: 1, jobId: row.id, type: "process_ai_report", reportId: row.report_id }));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function escalateExpiredAiWaits(db: D1Database, limit = 50): Promise<number> {
|
|
798
|
+
const expired = await db.prepare(`
|
|
799
|
+
SELECT r.id AS report_id, r.queue_id, r.queue_version_id, c.id AS config_id,
|
|
800
|
+
c.fallback_queue_id, fallback.active_version_id AS fallback_version_id
|
|
801
|
+
FROM reports r
|
|
802
|
+
JOIN ai_queue_config_versions c ON c.queue_version_id = r.queue_version_id
|
|
803
|
+
JOIN queue_definitions fallback ON fallback.id = c.fallback_queue_id
|
|
804
|
+
JOIN queue_versions fallback_version ON fallback_version.id = fallback.active_version_id
|
|
805
|
+
WHERE r.state = 'awaiting_reporter' AND c.enabled = 1 AND c.mode = 'autonomous_low_priority'
|
|
806
|
+
AND fallback.status = 'active' AND fallback_version.management_mode = 'human'
|
|
807
|
+
AND julianday(r.updated_at) + (c.max_wait_hours / 24.0) <= julianday(?1)
|
|
808
|
+
ORDER BY r.updated_at, r.id LIMIT ?2
|
|
809
|
+
`).bind(now(), Math.max(1, Math.min(100, limit))).all<{
|
|
810
|
+
report_id: string; queue_id: string; queue_version_id: string; config_id: string;
|
|
811
|
+
fallback_queue_id: string; fallback_version_id: string;
|
|
812
|
+
}>();
|
|
813
|
+
for (const report of expired.results) {
|
|
814
|
+
const createdAt = now();
|
|
815
|
+
await db.batch([
|
|
816
|
+
db.prepare(`
|
|
817
|
+
UPDATE reports SET state = 'human_review', queue_id = ?2, queue_version_id = ?3,
|
|
818
|
+
priority = MAX(priority, 65), updated_at = ?4
|
|
819
|
+
WHERE id = ?1 AND state = 'awaiting_reporter'
|
|
820
|
+
`).bind(report.report_id, report.fallback_queue_id, report.fallback_version_id, createdAt),
|
|
821
|
+
db.prepare(`
|
|
822
|
+
INSERT INTO report_state_history (
|
|
823
|
+
id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
|
|
824
|
+
) VALUES (?1, ?2, 'awaiting_reporter', 'human_review', 'ai_reporter_wait_expired',
|
|
825
|
+
'system', 'bounded-ai-v2', ?3)
|
|
826
|
+
`).bind(crypto.randomUUID(), report.report_id, createdAt),
|
|
827
|
+
usageStatement(db, report.config_id, { escalated: 1 }, createdAt),
|
|
828
|
+
auditEvent(db, {
|
|
829
|
+
key: `ai-wait-expired:${report.report_id}:${report.queue_version_id}`,
|
|
830
|
+
action: "ai.reporter_wait_expired_to_human",
|
|
831
|
+
actorId: "bounded-ai-v2",
|
|
832
|
+
targetType: "report",
|
|
833
|
+
targetId: report.report_id,
|
|
834
|
+
details: { queueId: report.queue_id, queueVersionId: report.queue_version_id, fallbackQueueId: report.fallback_queue_id },
|
|
835
|
+
createdAt,
|
|
836
|
+
}),
|
|
837
|
+
]);
|
|
838
|
+
}
|
|
839
|
+
return expired.results.length;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export async function markAiJobUnexpectedFailure(db: D1Database, jobId: string): Promise<void> {
|
|
843
|
+
const failedAt = now();
|
|
844
|
+
await db.prepare(`
|
|
845
|
+
UPDATE background_jobs SET state = 'failed', available_at = ?2,
|
|
846
|
+
last_error_code = 'ai_internal_error', updated_at = ?2
|
|
847
|
+
WHERE id = ?1 AND state = 'processing'
|
|
848
|
+
`).bind(jobId, failedAt).run();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export async function markAiJobEnqueued(db: D1Database, jobId: string, success: boolean): Promise<void> {
|
|
852
|
+
const updatedAt = now();
|
|
853
|
+
await db.prepare(`
|
|
854
|
+
UPDATE background_jobs SET state = ?2, last_error_code = ?3, updated_at = ?4
|
|
855
|
+
WHERE id = ?1 AND state IN ('pending', 'failed')
|
|
856
|
+
`).bind(jobId, success ? "enqueued" : "failed", success ? null : "ai_queue_send_failed", updatedAt).run();
|
|
857
|
+
}
|