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,685 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { ApiError } from "./report-http";
|
|
3
|
+
import type { ActionProposalJob, AiJob, DeliveryJob, EvidenceFetchJob, OperatorSession, ReportJob, WorkflowDispatchJob } from "./report-types";
|
|
4
|
+
|
|
5
|
+
type QueueJob = DeliveryJob | ReportJob | AiJob | EvidenceFetchJob | WorkflowDispatchJob | ActionProposalJob;
|
|
6
|
+
|
|
7
|
+
interface CountRow {
|
|
8
|
+
total: number;
|
|
9
|
+
pending: number;
|
|
10
|
+
delivering: number;
|
|
11
|
+
failed: number;
|
|
12
|
+
terminal_failed: number;
|
|
13
|
+
oldest_due_at: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RoutingCountRow {
|
|
17
|
+
total: number;
|
|
18
|
+
pending: number;
|
|
19
|
+
enqueued: number;
|
|
20
|
+
processing: number;
|
|
21
|
+
failed: number;
|
|
22
|
+
dead_letter: number;
|
|
23
|
+
oldest_available_at: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function now(): string {
|
|
27
|
+
return new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function auditEvent(
|
|
31
|
+
db: D1Database,
|
|
32
|
+
input: { idempotencyKey: string; targetType: string; targetId: string; details: unknown; createdAt: string },
|
|
33
|
+
): D1PreparedStatement {
|
|
34
|
+
return db.prepare(`
|
|
35
|
+
INSERT OR IGNORE INTO audit_events (
|
|
36
|
+
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
37
|
+
) VALUES (?1, ?2, 'queue.dead_lettered', 'system', 'queue-consumer', ?3, ?4, ?5, ?6)
|
|
38
|
+
`).bind(crypto.randomUUID(), input.idempotencyKey, input.targetType, input.targetId, canonicalJson(input.details), input.createdAt);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validJobType(value: QueueJob): "route_report" | "process_ai_report" | "fetch_evidence" | "dispatch_workflow" | "deliver_action" | "deliver_notification" | "deliver_email" | "apply_action_proposal" {
|
|
42
|
+
return value.type;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function recordDeadLetter(
|
|
46
|
+
db: D1Database,
|
|
47
|
+
sourceQueue: string,
|
|
48
|
+
sourceMessageId: string,
|
|
49
|
+
job: QueueJob | null,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const createdAt = now();
|
|
52
|
+
const jobType = job ? validJobType(job) : "invalid";
|
|
53
|
+
const jobId = job?.jobId ?? null;
|
|
54
|
+
const reportId = job?.type === "route_report" || job?.type === "process_ai_report" || job?.type === "fetch_evidence"
|
|
55
|
+
? job.reportId
|
|
56
|
+
: job?.type === "apply_action_proposal"
|
|
57
|
+
? (await db.prepare(`SELECT report_id AS reportId FROM action_proposals WHERE id = ?1 LIMIT 1`).bind(job.proposalId).first<{ reportId: string }>())?.reportId ?? null
|
|
58
|
+
: null;
|
|
59
|
+
const outboxId = job && (job.type === "deliver_action" || job.type === "deliver_notification" || job.type === "deliver_email" || job.type === "dispatch_workflow") ? job.outboxId : null;
|
|
60
|
+
const fingerprint = `${sourceQueue}:${jobId ?? `message:${sourceMessageId}`}`.slice(0, 500);
|
|
61
|
+
const deadLetterId = crypto.randomUUID();
|
|
62
|
+
const incidentId = crypto.randomUUID();
|
|
63
|
+
const statements: D1PreparedStatement[] = [
|
|
64
|
+
db.prepare(`
|
|
65
|
+
INSERT INTO operations_incidents (
|
|
66
|
+
id, installation_id, incident_key, severity, category, state,
|
|
67
|
+
title, summary, entity_type, entity_id, occurrence_count,
|
|
68
|
+
first_seen_at, last_seen_at, metadata_json
|
|
69
|
+
) VALUES (?1, 'default', ?2, 'high', 'queue_dead_letter', 'open',
|
|
70
|
+
'Queue delivery exhausted retries', ?3, ?4, ?5, 1, ?6, ?6, ?7)
|
|
71
|
+
ON CONFLICT(installation_id, incident_key) DO UPDATE SET
|
|
72
|
+
state = 'open', occurrence_count = operations_incidents.occurrence_count + 1,
|
|
73
|
+
last_seen_at = excluded.last_seen_at, metadata_json = excluded.metadata_json,
|
|
74
|
+
resolved_by = NULL, resolved_at = NULL, resolution_summary = NULL
|
|
75
|
+
`).bind(
|
|
76
|
+
incidentId, fingerprint, `The ${jobType} job exhausted Queue retries and requires repair.`,
|
|
77
|
+
job ? "queue_job" : "queue_message", jobId ?? sourceMessageId, createdAt,
|
|
78
|
+
canonicalJson({ sourceQueue, jobType, reportId, outboxId }),
|
|
79
|
+
),
|
|
80
|
+
db.prepare(`
|
|
81
|
+
INSERT INTO dead_letter_items (
|
|
82
|
+
id, fingerprint, source_queue, source_message_id, job_type, job_id, report_id, outbox_id,
|
|
83
|
+
incident_id, state, error_code, details_json, first_seen_at, last_seen_at
|
|
84
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
|
85
|
+
(SELECT id FROM operations_incidents WHERE installation_id = 'default' AND incident_key = ?2),
|
|
86
|
+
'open', 'queue_retries_exhausted', ?9, ?10, ?10)
|
|
87
|
+
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
88
|
+
source_message_id = excluded.source_message_id,
|
|
89
|
+
incident_id = excluded.incident_id,
|
|
90
|
+
state = 'open', error_code = excluded.error_code,
|
|
91
|
+
occurrence_count = dead_letter_items.occurrence_count + 1,
|
|
92
|
+
details_json = excluded.details_json, last_seen_at = excluded.last_seen_at,
|
|
93
|
+
resolved_at = NULL
|
|
94
|
+
`).bind(
|
|
95
|
+
deadLetterId, fingerprint, sourceQueue.slice(0, 200), sourceMessageId.slice(0, 200), jobType,
|
|
96
|
+
jobId?.slice(0, 200) ?? null, reportId, outboxId,
|
|
97
|
+
canonicalJson({ version: 1, jobType, hasReportReference: Boolean(reportId), hasOutboxReference: Boolean(outboxId) }),
|
|
98
|
+
createdAt,
|
|
99
|
+
),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
if (job?.type === "route_report") {
|
|
103
|
+
statements.push(
|
|
104
|
+
db.prepare(`
|
|
105
|
+
UPDATE background_jobs
|
|
106
|
+
SET state = 'dead_letter', last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
107
|
+
WHERE id = ?1 AND state != 'complete'
|
|
108
|
+
`).bind(job.jobId, createdAt),
|
|
109
|
+
db.prepare(`
|
|
110
|
+
UPDATE reports
|
|
111
|
+
SET routing_status = 'failed', routing_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
112
|
+
WHERE id = ?1 AND routing_status != 'complete'
|
|
113
|
+
`).bind(job.reportId, createdAt),
|
|
114
|
+
auditEvent(db, {
|
|
115
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
116
|
+
targetType: "report",
|
|
117
|
+
targetId: job.reportId,
|
|
118
|
+
details: { jobId: job.jobId, sourceQueue, errorCode: "queue_retries_exhausted" },
|
|
119
|
+
createdAt,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
} else if (job?.type === "process_ai_report") {
|
|
123
|
+
const fallback = await db.prepare(`
|
|
124
|
+
SELECT q.id, q.active_version_id
|
|
125
|
+
FROM background_jobs j
|
|
126
|
+
JOIN ai_queue_config_versions c ON c.id = j.ai_config_version_id
|
|
127
|
+
JOIN queue_definitions q ON q.id = c.fallback_queue_id
|
|
128
|
+
JOIN queue_versions qv ON qv.id = q.active_version_id
|
|
129
|
+
WHERE j.id = ?1 AND q.status = 'active' AND qv.management_mode = 'human'
|
|
130
|
+
LIMIT 1
|
|
131
|
+
`).bind(job.jobId).first<{ id: string; active_version_id: string }>()
|
|
132
|
+
?? await db.prepare(`
|
|
133
|
+
SELECT q.id, q.active_version_id
|
|
134
|
+
FROM queue_definitions q JOIN queue_versions qv ON qv.id = q.active_version_id
|
|
135
|
+
WHERE q.purpose = 'reports' AND q.status = 'active' AND qv.management_mode = 'human'
|
|
136
|
+
ORDER BY q.position, q.id LIMIT 1
|
|
137
|
+
`).first<{ id: string; active_version_id: string }>();
|
|
138
|
+
statements.push(
|
|
139
|
+
db.prepare(`
|
|
140
|
+
UPDATE background_jobs
|
|
141
|
+
SET state = 'dead_letter', last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
142
|
+
WHERE id = ?1 AND state != 'complete'
|
|
143
|
+
`).bind(job.jobId, createdAt),
|
|
144
|
+
db.prepare(`
|
|
145
|
+
INSERT INTO report_state_history (
|
|
146
|
+
id, report_id, background_job_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
|
|
147
|
+
)
|
|
148
|
+
SELECT ?1, ?2, ?3, 'ai_review', 'human_review', 'ai_queue_retries_exhausted',
|
|
149
|
+
'system', 'bounded-ai-v2', ?4
|
|
150
|
+
FROM reports WHERE id = ?2 AND state = 'ai_review'
|
|
151
|
+
`).bind(crypto.randomUUID(), job.reportId, job.jobId, createdAt),
|
|
152
|
+
db.prepare(`
|
|
153
|
+
UPDATE reports SET state = 'human_review', queue_id = ?2, queue_version_id = ?3, priority = MAX(priority, 60),
|
|
154
|
+
routing_error_code = 'ai_queue_retries_exhausted', updated_at = ?4
|
|
155
|
+
WHERE id = ?1 AND state = 'ai_review'
|
|
156
|
+
`).bind(job.reportId, fallback?.id ?? null, fallback?.active_version_id ?? null, createdAt),
|
|
157
|
+
auditEvent(db, {
|
|
158
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
159
|
+
targetType: "report",
|
|
160
|
+
targetId: job.reportId,
|
|
161
|
+
details: { jobId: job.jobId, sourceQueue, errorCode: "ai_queue_retries_exhausted", fallbackQueue: fallback?.id ?? null },
|
|
162
|
+
createdAt,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
} else if (job?.type === "fetch_evidence") {
|
|
166
|
+
statements.push(
|
|
167
|
+
db.prepare(`
|
|
168
|
+
UPDATE evidence_fetch_requests SET state = 'failed',
|
|
169
|
+
last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
170
|
+
WHERE id = ?1 AND state != 'available'
|
|
171
|
+
`).bind(job.requestId, createdAt),
|
|
172
|
+
auditEvent(db, {
|
|
173
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
174
|
+
targetType: "evidence_fetch_request",
|
|
175
|
+
targetId: job.requestId,
|
|
176
|
+
details: { jobId: job.jobId, reportId: job.reportId, sourceQueue, errorCode: "queue_retries_exhausted" },
|
|
177
|
+
createdAt,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
} else if (job?.type === "dispatch_workflow") {
|
|
181
|
+
statements.push(
|
|
182
|
+
db.prepare(`
|
|
183
|
+
UPDATE workflow_dispatch_outbox
|
|
184
|
+
SET state = 'dead_letter', lease_expires_at = NULL,
|
|
185
|
+
last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
186
|
+
WHERE id = ?1 AND state != 'delivered'
|
|
187
|
+
`).bind(job.outboxId, createdAt),
|
|
188
|
+
db.prepare(`
|
|
189
|
+
UPDATE workflow_runs
|
|
190
|
+
SET state = 'repair_required', error_code = 'workflow_dispatch_queue_retries_exhausted',
|
|
191
|
+
error_summary = 'Workflow dispatch exhausted infrastructure Queue retries.',
|
|
192
|
+
updated_at = ?2, revision = revision + 1
|
|
193
|
+
WHERE id = ?1 AND state IN ('created','dispatch_pending','queued')
|
|
194
|
+
`).bind(job.runId, createdAt),
|
|
195
|
+
auditEvent(db, {
|
|
196
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
197
|
+
targetType: "workflow_run",
|
|
198
|
+
targetId: job.runId,
|
|
199
|
+
details: { sourceQueue, outboxId: job.outboxId, errorCode: "queue_retries_exhausted" },
|
|
200
|
+
createdAt,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
} else if (job?.type === "deliver_action") {
|
|
204
|
+
statements.push(
|
|
205
|
+
db.prepare(`
|
|
206
|
+
UPDATE decision_action_outbox
|
|
207
|
+
SET status = 'failed', terminal = 1, lease_expires_at = NULL,
|
|
208
|
+
last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
209
|
+
WHERE id = ?1 AND terminal = 0
|
|
210
|
+
`).bind(job.outboxId, createdAt),
|
|
211
|
+
auditEvent(db, {
|
|
212
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
213
|
+
targetType: "application_action",
|
|
214
|
+
targetId: job.outboxId,
|
|
215
|
+
details: { sourceQueue, errorCode: "queue_retries_exhausted" },
|
|
216
|
+
createdAt,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
} else if (job?.type === "deliver_notification") {
|
|
220
|
+
statements.push(
|
|
221
|
+
db.prepare(`
|
|
222
|
+
UPDATE notification_outbox
|
|
223
|
+
SET status = 'failed', terminal = 1, lease_expires_at = NULL,
|
|
224
|
+
last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
225
|
+
WHERE id = ?1 AND terminal = 0
|
|
226
|
+
`).bind(job.outboxId, createdAt),
|
|
227
|
+
db.prepare(`
|
|
228
|
+
UPDATE notices SET delivery_state = 'failed', updated_at = ?2
|
|
229
|
+
WHERE id = (SELECT notice_id FROM notification_outbox WHERE id = ?1)
|
|
230
|
+
`).bind(job.outboxId, createdAt),
|
|
231
|
+
db.prepare(`
|
|
232
|
+
UPDATE case_messages SET delivery_state = 'failed'
|
|
233
|
+
WHERE id = (
|
|
234
|
+
SELECT n.message_id FROM notices n
|
|
235
|
+
JOIN notification_outbox o ON o.notice_id = n.id
|
|
236
|
+
WHERE o.id = ?1
|
|
237
|
+
)
|
|
238
|
+
`).bind(job.outboxId),
|
|
239
|
+
auditEvent(db, {
|
|
240
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
241
|
+
targetType: "notification",
|
|
242
|
+
targetId: job.outboxId,
|
|
243
|
+
details: { sourceQueue, errorCode: "queue_retries_exhausted" },
|
|
244
|
+
createdAt,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
} else if (job?.type === "deliver_email") {
|
|
248
|
+
statements.push(
|
|
249
|
+
db.prepare(`
|
|
250
|
+
UPDATE email_deliveries SET state = 'failed',
|
|
251
|
+
last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
252
|
+
WHERE id = ?1 AND state != 'delivered'
|
|
253
|
+
`).bind(job.outboxId, createdAt),
|
|
254
|
+
db.prepare(`
|
|
255
|
+
UPDATE case_messages SET delivery_state = 'failed'
|
|
256
|
+
WHERE id = (SELECT message_id FROM email_deliveries WHERE id = ?1)
|
|
257
|
+
AND delivery_state != 'delivered'
|
|
258
|
+
`).bind(job.outboxId),
|
|
259
|
+
auditEvent(db, {
|
|
260
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
261
|
+
targetType: "email_delivery",
|
|
262
|
+
targetId: job.outboxId,
|
|
263
|
+
details: { sourceQueue, errorCode: "queue_retries_exhausted" },
|
|
264
|
+
createdAt,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
} else if (job?.type === "apply_action_proposal") {
|
|
268
|
+
statements.push(
|
|
269
|
+
db.prepare(`
|
|
270
|
+
UPDATE action_proposals SET state = 'failed', delivery_terminal = 1,
|
|
271
|
+
lease_expires_at = NULL, last_error_code = 'queue_retries_exhausted', updated_at = ?2
|
|
272
|
+
WHERE id = ?1 AND state != 'applied'
|
|
273
|
+
`).bind(job.proposalId, createdAt),
|
|
274
|
+
auditEvent(db, {
|
|
275
|
+
idempotencyKey: `dead-letter:${fingerprint}`,
|
|
276
|
+
targetType: "action_proposal",
|
|
277
|
+
targetId: job.proposalId,
|
|
278
|
+
details: { runId: job.runId, sourceQueue, errorCode: "queue_retries_exhausted" },
|
|
279
|
+
createdAt,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
await db.batch(statements);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function numeric(value: number | null | undefined): number {
|
|
287
|
+
return Number.isFinite(value) ? Number(value) : 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function loadOperationsSnapshot(db: D1Database): Promise<Record<string, unknown>> {
|
|
291
|
+
const [routing, actions, notifications, emails, workflows, evidence, budgets, incidents, surge, deadLetters, failures] = await Promise.all([
|
|
292
|
+
db.prepare(`
|
|
293
|
+
SELECT COUNT(*) AS total,
|
|
294
|
+
SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
295
|
+
SUM(CASE WHEN state = 'enqueued' THEN 1 ELSE 0 END) AS enqueued,
|
|
296
|
+
SUM(CASE WHEN state = 'processing' THEN 1 ELSE 0 END) AS processing,
|
|
297
|
+
SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed,
|
|
298
|
+
SUM(CASE WHEN state = 'dead_letter' THEN 1 ELSE 0 END) AS dead_letter,
|
|
299
|
+
MIN(CASE WHEN state IN ('pending', 'failed') THEN available_at END) AS oldest_available_at
|
|
300
|
+
FROM background_jobs WHERE job_type = 'route_report'
|
|
301
|
+
`).first<RoutingCountRow>(),
|
|
302
|
+
db.prepare(`
|
|
303
|
+
SELECT COUNT(*) AS total,
|
|
304
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
305
|
+
SUM(CASE WHEN status = 'delivering' THEN 1 ELSE 0 END) AS delivering,
|
|
306
|
+
SUM(CASE WHEN status = 'failed' AND terminal = 0 THEN 1 ELSE 0 END) AS failed,
|
|
307
|
+
SUM(CASE WHEN status = 'failed' AND terminal = 1 THEN 1 ELSE 0 END) AS terminal_failed,
|
|
308
|
+
MIN(CASE WHEN terminal = 0 AND status IN ('pending', 'failed') THEN next_attempt_at END) AS oldest_due_at
|
|
309
|
+
FROM decision_action_outbox
|
|
310
|
+
`).first<CountRow>(),
|
|
311
|
+
db.prepare(`
|
|
312
|
+
SELECT COUNT(*) AS total,
|
|
313
|
+
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
314
|
+
SUM(CASE WHEN status = 'delivering' THEN 1 ELSE 0 END) AS delivering,
|
|
315
|
+
SUM(CASE WHEN status = 'failed' AND terminal = 0 THEN 1 ELSE 0 END) AS failed,
|
|
316
|
+
SUM(CASE WHEN status = 'failed' AND terminal = 1 THEN 1 ELSE 0 END) AS terminal_failed,
|
|
317
|
+
MIN(CASE WHEN terminal = 0 AND status IN ('pending', 'failed') THEN next_attempt_at END) AS oldest_due_at
|
|
318
|
+
FROM notification_outbox
|
|
319
|
+
`).first<CountRow>(),
|
|
320
|
+
db.prepare(`
|
|
321
|
+
SELECT COUNT(*) AS total,
|
|
322
|
+
SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
323
|
+
SUM(CASE WHEN state = 'sending' THEN 1 ELSE 0 END) AS delivering,
|
|
324
|
+
SUM(CASE WHEN state = 'failed' AND attempt_count < 8 THEN 1 ELSE 0 END) AS failed,
|
|
325
|
+
SUM(CASE WHEN state = 'failed' AND attempt_count >= 8 THEN 1 ELSE 0 END) AS terminal_failed,
|
|
326
|
+
MIN(CASE WHEN state IN ('pending','failed') THEN COALESCE(next_attempt_at, created_at) END) AS oldest_due_at
|
|
327
|
+
FROM email_deliveries
|
|
328
|
+
`).first<CountRow>(),
|
|
329
|
+
db.prepare(`
|
|
330
|
+
SELECT COUNT(*) AS total,
|
|
331
|
+
SUM(CASE WHEN state IN ('created','dispatch_pending','queued','running','waiting','paused') THEN 1 ELSE 0 END) AS active,
|
|
332
|
+
SUM(CASE WHEN state = 'repair_required' THEN 1 ELSE 0 END) AS repairRequired,
|
|
333
|
+
SUM(CASE WHEN state = 'failed' THEN 1 ELSE 0 END) AS failed,
|
|
334
|
+
MIN(CASE WHEN state IN ('created','dispatch_pending','queued','running','waiting','paused','repair_required') THEN updated_at END) AS oldestActiveAt
|
|
335
|
+
FROM workflow_runs
|
|
336
|
+
`).first<Record<string, number | string | null>>(),
|
|
337
|
+
db.prepare(`
|
|
338
|
+
SELECT COUNT(*) AS total,
|
|
339
|
+
SUM(CASE WHEN state = 'pending' THEN 1 ELSE 0 END) AS pending,
|
|
340
|
+
SUM(CASE WHEN state = 'fetching' THEN 1 ELSE 0 END) AS fetching,
|
|
341
|
+
SUM(CASE WHEN state IN ('failed','unavailable') THEN 1 ELSE 0 END) AS unavailable,
|
|
342
|
+
MIN(CASE WHEN state IN ('pending','fetching') THEN updated_at END) AS oldestActiveAt
|
|
343
|
+
FROM evidence_fetch_requests
|
|
344
|
+
`).first<Record<string, number | string | null>>(),
|
|
345
|
+
db.prepare(`
|
|
346
|
+
SELECT COUNT(*) AS policyCount,
|
|
347
|
+
(SELECT COUNT(*) FROM budget_reservations WHERE status = 'reserved') AS activeReservations,
|
|
348
|
+
(SELECT COALESCE(SUM(consumed_value), 0) FROM budget_usage) AS consumedUnits,
|
|
349
|
+
(SELECT MIN(expires_at) FROM budget_reservations WHERE status = 'reserved') AS nextReservationExpiry
|
|
350
|
+
FROM budget_policies WHERE retired_at IS NULL
|
|
351
|
+
`).first<Record<string, number | string | null>>(),
|
|
352
|
+
db.prepare(`
|
|
353
|
+
SELECT COUNT(*) AS total,
|
|
354
|
+
SUM(CASE WHEN state = 'open' THEN 1 ELSE 0 END) AS open,
|
|
355
|
+
SUM(CASE WHEN state = 'acknowledged' THEN 1 ELSE 0 END) AS acknowledged,
|
|
356
|
+
SUM(CASE WHEN severity IN ('high','critical') AND state != 'resolved' THEN 1 ELSE 0 END) AS highPriority
|
|
357
|
+
FROM operations_incidents
|
|
358
|
+
`).first<Record<string, number | string | null>>(),
|
|
359
|
+
db.prepare(`
|
|
360
|
+
SELECT state AS mode, activated_at AS enteredAt, recover_after AS recoverAfter,
|
|
361
|
+
reason_codes_json AS reasonCodesJson, revision, updated_at AS updatedAt
|
|
362
|
+
FROM abuse_surge_states WHERE installation_id = 'default' LIMIT 1
|
|
363
|
+
`).first<Record<string, unknown>>(),
|
|
364
|
+
db.prepare(`
|
|
365
|
+
SELECT id, source_queue AS sourceQueue, job_type AS jobType, job_id AS jobId,
|
|
366
|
+
report_id AS reportId, outbox_id AS outboxId, state, error_code AS errorCode,
|
|
367
|
+
occurrence_count AS occurrenceCount, first_seen_at AS firstSeenAt, last_seen_at AS lastSeenAt
|
|
368
|
+
FROM dead_letter_items WHERE state != 'resolved'
|
|
369
|
+
ORDER BY last_seen_at DESC, id DESC LIMIT 100
|
|
370
|
+
`).all<Record<string, unknown>>(),
|
|
371
|
+
db.prepare(`
|
|
372
|
+
SELECT 'action' AS deliveryType, o.id, r.public_reference AS reportReference,
|
|
373
|
+
o.status, o.attempt_count AS attemptCount, o.last_error_code AS errorCode, o.updated_at AS updatedAt
|
|
374
|
+
FROM decision_action_outbox o JOIN reports r ON r.id = o.report_id
|
|
375
|
+
WHERE o.status = 'failed'
|
|
376
|
+
UNION ALL
|
|
377
|
+
SELECT 'notification' AS deliveryType, o.id, r.public_reference AS reportReference,
|
|
378
|
+
o.status, o.attempt_count AS attemptCount, o.last_error_code AS errorCode, o.updated_at AS updatedAt
|
|
379
|
+
FROM notification_outbox o JOIN notices n ON n.id = o.notice_id JOIN reports r ON r.id = n.report_id
|
|
380
|
+
WHERE o.status = 'failed'
|
|
381
|
+
ORDER BY updatedAt DESC LIMIT 100
|
|
382
|
+
`).all<Record<string, unknown>>(),
|
|
383
|
+
]);
|
|
384
|
+
return {
|
|
385
|
+
generatedAt: now(),
|
|
386
|
+
routing: {
|
|
387
|
+
total: numeric(routing?.total), pending: numeric(routing?.pending), enqueued: numeric(routing?.enqueued),
|
|
388
|
+
processing: numeric(routing?.processing), failed: numeric(routing?.failed), deadLetter: numeric(routing?.dead_letter),
|
|
389
|
+
oldestAvailableAt: routing?.oldest_available_at ?? null,
|
|
390
|
+
},
|
|
391
|
+
actions: {
|
|
392
|
+
total: numeric(actions?.total), pending: numeric(actions?.pending), delivering: numeric(actions?.delivering),
|
|
393
|
+
retrying: numeric(actions?.failed), terminalFailed: numeric(actions?.terminal_failed), oldestDueAt: actions?.oldest_due_at ?? null,
|
|
394
|
+
},
|
|
395
|
+
notifications: {
|
|
396
|
+
total: numeric(notifications?.total), pending: numeric(notifications?.pending), delivering: numeric(notifications?.delivering),
|
|
397
|
+
retrying: numeric(notifications?.failed), terminalFailed: numeric(notifications?.terminal_failed), oldestDueAt: notifications?.oldest_due_at ?? null,
|
|
398
|
+
},
|
|
399
|
+
emails: {
|
|
400
|
+
total: numeric(emails?.total), pending: numeric(emails?.pending), sending: numeric(emails?.delivering),
|
|
401
|
+
retrying: numeric(emails?.failed), terminalFailed: numeric(emails?.terminal_failed), oldestDueAt: emails?.oldest_due_at ?? null,
|
|
402
|
+
},
|
|
403
|
+
workflows: workflows ?? {},
|
|
404
|
+
evidence: evidence ?? {},
|
|
405
|
+
budgets: budgets ?? {},
|
|
406
|
+
incidents: incidents ?? {},
|
|
407
|
+
intakeSurge: surge ? {
|
|
408
|
+
...surge,
|
|
409
|
+
reasonCodes: typeof surge.reasonCodesJson === "string" ? JSON.parse(surge.reasonCodesJson) : [],
|
|
410
|
+
reasonCodesJson: undefined,
|
|
411
|
+
} : { mode: "normal" },
|
|
412
|
+
deadLetters: deadLetters.results,
|
|
413
|
+
deliveryFailures: failures.results,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function listOperationsIncidents(
|
|
418
|
+
db: D1Database,
|
|
419
|
+
input: { state?: string | null; severity?: string | null; limit?: number },
|
|
420
|
+
): Promise<Record<string, unknown>[]> {
|
|
421
|
+
const rows = await db.prepare(`
|
|
422
|
+
SELECT id, incident_key AS incidentKey, severity, category, state, title, summary,
|
|
423
|
+
entity_type AS entityType, entity_id AS entityId, occurrence_count AS occurrenceCount,
|
|
424
|
+
first_seen_at AS firstSeenAt, last_seen_at AS lastSeenAt,
|
|
425
|
+
acknowledged_by AS acknowledgedBy, acknowledged_at AS acknowledgedAt,
|
|
426
|
+
resolved_by AS resolvedBy, resolved_at AS resolvedAt,
|
|
427
|
+
resolution_summary AS resolutionSummary, metadata_json AS metadataJson
|
|
428
|
+
FROM operations_incidents
|
|
429
|
+
WHERE installation_id = 'default' AND (?1 IS NULL OR state = ?1)
|
|
430
|
+
AND (?2 IS NULL OR severity = ?2)
|
|
431
|
+
ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END,
|
|
432
|
+
last_seen_at DESC, id DESC LIMIT ?3
|
|
433
|
+
`).bind(input.state ?? null, input.severity ?? null, Math.max(1, Math.min(100, input.limit ?? 50)))
|
|
434
|
+
.all<Record<string, unknown>>();
|
|
435
|
+
return rows.results.map((row) => ({
|
|
436
|
+
...row,
|
|
437
|
+
metadata: typeof row.metadataJson === "string" ? JSON.parse(row.metadataJson) : {},
|
|
438
|
+
metadataJson: undefined,
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function loadOperationsIncident(db: D1Database, incidentId: string): Promise<Record<string, unknown> | null> {
|
|
443
|
+
const incident = await db.prepare(`SELECT * FROM operations_incidents WHERE id = ?1 AND installation_id = 'default' LIMIT 1`)
|
|
444
|
+
.bind(incidentId).first<Record<string, unknown>>();
|
|
445
|
+
if (!incident) return null;
|
|
446
|
+
const [deadLetters, repairs] = await Promise.all([
|
|
447
|
+
db.prepare(`SELECT * FROM dead_letter_items WHERE incident_id = ?1 ORDER BY last_seen_at DESC, id DESC`).bind(incidentId).all(),
|
|
448
|
+
db.prepare(`SELECT * FROM repair_actions WHERE incident_id = ?1 ORDER BY planned_at DESC, id DESC`).bind(incidentId).all(),
|
|
449
|
+
]);
|
|
450
|
+
return {
|
|
451
|
+
incident: {
|
|
452
|
+
...incident,
|
|
453
|
+
metadata: typeof incident.metadata_json === "string" ? JSON.parse(incident.metadata_json) : {},
|
|
454
|
+
metadata_json: undefined,
|
|
455
|
+
},
|
|
456
|
+
deadLetters: deadLetters.results,
|
|
457
|
+
repairs: repairs.results,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function assertIncidentMutationKey(
|
|
462
|
+
db: D1Database,
|
|
463
|
+
idempotencyKey: string,
|
|
464
|
+
action: string,
|
|
465
|
+
incidentId: string,
|
|
466
|
+
): Promise<boolean> {
|
|
467
|
+
const prior = await db.prepare(`SELECT action, target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 LIMIT 1`)
|
|
468
|
+
.bind(idempotencyKey).first<{ action: string; targetId: string }>();
|
|
469
|
+
if (!prior) return false;
|
|
470
|
+
if (prior.action !== action || prior.targetId !== incidentId) {
|
|
471
|
+
throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another operations mutation.");
|
|
472
|
+
}
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function operationsAudit(
|
|
477
|
+
db: D1Database,
|
|
478
|
+
input: { key: string; action: string; incidentId: string; session: OperatorSession; details: unknown; createdAt: string },
|
|
479
|
+
): D1PreparedStatement {
|
|
480
|
+
return db.prepare(`
|
|
481
|
+
INSERT INTO audit_events (
|
|
482
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
483
|
+
target_type, target_id, details_json, created_at
|
|
484
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, 'operations_incident', ?6, ?7, ?8)
|
|
485
|
+
`).bind(
|
|
486
|
+
crypto.randomUUID(), input.key, input.action, input.session.actor.type,
|
|
487
|
+
input.session.actor.id, input.incidentId, canonicalJson(input.details), input.createdAt,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function acknowledgeOperationsIncident(
|
|
492
|
+
db: D1Database,
|
|
493
|
+
incidentId: string,
|
|
494
|
+
session: OperatorSession,
|
|
495
|
+
idempotencyKey: string,
|
|
496
|
+
): Promise<{ incidentId: string; state: string; idempotentReplay: boolean }> {
|
|
497
|
+
const key = `incident-ack:${idempotencyKey}`;
|
|
498
|
+
if (await assertIncidentMutationKey(db, key, "operations.incident_acknowledged", incidentId)) {
|
|
499
|
+
return { incidentId, state: "acknowledged", idempotentReplay: true };
|
|
500
|
+
}
|
|
501
|
+
const incident = await db.prepare(`SELECT state FROM operations_incidents WHERE id = ?1 LIMIT 1`).bind(incidentId).first<{ state: string }>();
|
|
502
|
+
if (!incident) throw new ApiError(404, "operations_incident_not_found", "The operations incident does not exist.");
|
|
503
|
+
if (incident.state === "resolved") throw new ApiError(409, "operations_incident_resolved", "A resolved incident cannot be acknowledged.");
|
|
504
|
+
const timestamp = now();
|
|
505
|
+
await db.batch([
|
|
506
|
+
db.prepare(`UPDATE operations_incidents SET state = 'acknowledged', acknowledged_by = ?2, acknowledged_at = COALESCE(acknowledged_at, ?3) WHERE id = ?1 AND state != 'resolved'`)
|
|
507
|
+
.bind(incidentId, session.actor.id, timestamp),
|
|
508
|
+
db.prepare(`UPDATE dead_letter_items SET state = 'acknowledged' WHERE incident_id = ?1 AND state = 'open'`).bind(incidentId),
|
|
509
|
+
operationsAudit(db, { key, action: "operations.incident_acknowledged", incidentId, session, details: {}, createdAt: timestamp }),
|
|
510
|
+
]);
|
|
511
|
+
return { incidentId, state: "acknowledged", idempotentReplay: false };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function resolveOperationsIncident(
|
|
515
|
+
db: D1Database,
|
|
516
|
+
incidentId: string,
|
|
517
|
+
resolution: { code: string; summary: string },
|
|
518
|
+
session: OperatorSession,
|
|
519
|
+
idempotencyKey: string,
|
|
520
|
+
): Promise<{ incidentId: string; state: string; idempotentReplay: boolean }> {
|
|
521
|
+
const key = `incident-resolve:${idempotencyKey}`;
|
|
522
|
+
if (await assertIncidentMutationKey(db, key, "operations.incident_resolved", incidentId)) {
|
|
523
|
+
return { incidentId, state: "resolved", idempotentReplay: true };
|
|
524
|
+
}
|
|
525
|
+
if (!/^[a-z][a-z0-9_]{1,63}$/u.test(resolution.code) || resolution.summary.trim().length < 3 || resolution.summary.length > 2_000) {
|
|
526
|
+
throw new ApiError(400, "incident_resolution_invalid", "A valid resolution code and summary are required.");
|
|
527
|
+
}
|
|
528
|
+
const incident = await db.prepare(`SELECT state FROM operations_incidents WHERE id = ?1 LIMIT 1`).bind(incidentId).first<{ state: string }>();
|
|
529
|
+
if (!incident) throw new ApiError(404, "operations_incident_not_found", "The operations incident does not exist.");
|
|
530
|
+
const timestamp = now();
|
|
531
|
+
const summary = `[${resolution.code}] ${resolution.summary.trim()}`;
|
|
532
|
+
await db.batch([
|
|
533
|
+
db.prepare(`UPDATE operations_incidents SET state = 'resolved', resolved_by = ?2, resolved_at = ?3, resolution_summary = ?4 WHERE id = ?1`)
|
|
534
|
+
.bind(incidentId, session.actor.id, timestamp, summary),
|
|
535
|
+
db.prepare(`UPDATE dead_letter_items SET state = 'resolved', resolved_at = ?2 WHERE incident_id = ?1 AND state != 'resolved'`)
|
|
536
|
+
.bind(incidentId, timestamp),
|
|
537
|
+
operationsAudit(db, { key, action: "operations.incident_resolved", incidentId, session, details: { resolutionCode: resolution.code, summary: resolution.summary.trim() }, createdAt: timestamp }),
|
|
538
|
+
]);
|
|
539
|
+
return { incidentId, state: "resolved", idempotentReplay: false };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function requestIdFromJob(jobType: string, jobId: string | null): string | null {
|
|
543
|
+
if (!jobId) return null;
|
|
544
|
+
const prefix = jobType === "fetch_evidence" ? "evidence:" : null;
|
|
545
|
+
return prefix && jobId.startsWith(prefix) ? jobId.slice(prefix.length) : null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function retryOperationsIncident(
|
|
549
|
+
db: D1Database,
|
|
550
|
+
incidentId: string,
|
|
551
|
+
session: OperatorSession,
|
|
552
|
+
idempotencyKey: string,
|
|
553
|
+
): Promise<{ incidentId: string; repairActionId: string; state: string; idempotentReplay: boolean }> {
|
|
554
|
+
const key = `incident-retry:${idempotencyKey}`;
|
|
555
|
+
const existing = await db.prepare(`SELECT id FROM repair_actions WHERE operation_key = ?1 LIMIT 1`).bind(key).first<{ id: string }>();
|
|
556
|
+
if (existing) return { incidentId, repairActionId: existing.id, state: "applied", idempotentReplay: true };
|
|
557
|
+
const item = await db.prepare(`
|
|
558
|
+
SELECT d.id, d.job_type AS jobType, d.job_id AS jobId, d.outbox_id AS outboxId,
|
|
559
|
+
d.report_id AS reportId, i.state AS incidentState
|
|
560
|
+
FROM operations_incidents i JOIN dead_letter_items d ON d.incident_id = i.id
|
|
561
|
+
WHERE i.id = ?1 ORDER BY d.last_seen_at DESC, d.id DESC LIMIT 1
|
|
562
|
+
`).bind(incidentId).first<{ id: string; jobType: string; jobId: string | null; outboxId: string | null; reportId: string | null; incidentState: string }>();
|
|
563
|
+
if (!item) throw new ApiError(404, "operations_incident_not_repairable", "The incident has no typed dead-letter record to repair.");
|
|
564
|
+
if (item.incidentState === "resolved") throw new ApiError(409, "operations_incident_resolved", "A resolved incident cannot be retried.");
|
|
565
|
+
const timestamp = now();
|
|
566
|
+
let entityType: string;
|
|
567
|
+
let entityId: string;
|
|
568
|
+
let repair: D1PreparedStatement;
|
|
569
|
+
if (item.jobType === "route_report" || item.jobType === "process_ai_report") {
|
|
570
|
+
if (!item.jobId) throw new ApiError(409, "operations_repair_reference_missing", "The background job reference is missing.");
|
|
571
|
+
entityType = "background_job"; entityId = item.jobId;
|
|
572
|
+
repair = db.prepare(`UPDATE background_jobs SET state = 'pending', attempt_count = 0, available_at = ?2, last_error_code = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
573
|
+
.bind(entityId, timestamp);
|
|
574
|
+
} else if (item.jobType === "fetch_evidence") {
|
|
575
|
+
const requestId = requestIdFromJob(item.jobType, item.jobId);
|
|
576
|
+
if (!requestId) throw new ApiError(409, "operations_repair_reference_missing", "The evidence request reference is missing.");
|
|
577
|
+
entityType = "evidence_fetch_request"; entityId = requestId;
|
|
578
|
+
repair = db.prepare(`UPDATE evidence_fetch_requests SET state = 'pending', attempt_count = 0, next_attempt_at = ?2, last_error_code = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
579
|
+
.bind(entityId, timestamp);
|
|
580
|
+
} else if (item.jobType === "dispatch_workflow") {
|
|
581
|
+
if (!item.outboxId) throw new ApiError(409, "operations_repair_reference_missing", "The workflow outbox reference is missing.");
|
|
582
|
+
entityType = "workflow_dispatch_outbox"; entityId = item.outboxId;
|
|
583
|
+
repair = db.prepare(`UPDATE workflow_dispatch_outbox SET state = 'pending', attempt_count = 0, available_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
584
|
+
.bind(entityId, timestamp);
|
|
585
|
+
} else if (item.jobType === "deliver_action" || item.jobType === "deliver_notification") {
|
|
586
|
+
if (!item.outboxId) throw new ApiError(409, "operations_repair_reference_missing", "The delivery outbox reference is missing.");
|
|
587
|
+
entityType = item.jobType === "deliver_action" ? "decision_action_outbox" : "notification_outbox";
|
|
588
|
+
entityId = item.outboxId;
|
|
589
|
+
const table = item.jobType === "deliver_action" ? "decision_action_outbox" : "notification_outbox";
|
|
590
|
+
repair = db.prepare(`UPDATE ${table} SET status = 'pending', terminal = 0, attempt_count = 0, next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
591
|
+
.bind(entityId, timestamp);
|
|
592
|
+
} else if (item.jobType === "deliver_email") {
|
|
593
|
+
if (!item.outboxId) throw new ApiError(409, "operations_repair_reference_missing", "The email delivery reference is missing.");
|
|
594
|
+
entityType = "email_delivery"; entityId = item.outboxId;
|
|
595
|
+
repair = db.prepare(`UPDATE email_deliveries SET state = 'pending', attempt_count = 0, next_attempt_at = ?2, last_error_code = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
596
|
+
.bind(entityId, timestamp);
|
|
597
|
+
} else if (item.jobType === "apply_action_proposal") {
|
|
598
|
+
if (!item.jobId?.startsWith("action-proposal:")) throw new ApiError(409, "operations_repair_reference_missing", "The action proposal reference is missing.");
|
|
599
|
+
entityType = "action_proposal"; entityId = item.jobId.slice("action-proposal:".length);
|
|
600
|
+
repair = db.prepare(`UPDATE action_proposals SET state = 'queued', delivery_terminal = 0, delivery_attempt_count = 0, next_attempt_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state != 'applied'`)
|
|
601
|
+
.bind(entityId, timestamp);
|
|
602
|
+
} else {
|
|
603
|
+
throw new ApiError(409, "operations_incident_not_repairable", "This incident cannot be repaired automatically.");
|
|
604
|
+
}
|
|
605
|
+
const repairActionId = crypto.randomUUID();
|
|
606
|
+
await db.batch([
|
|
607
|
+
repair,
|
|
608
|
+
db.prepare(`INSERT INTO repair_actions (id, incident_id, action_type, entity_type, entity_id, operation_key, state, before_json, after_json, planned_by, planned_at, applied_at) VALUES (?1, ?2, 'retry_authoritative_record', ?3, ?4, ?5, 'applied', ?6, ?7, ?8, ?9, ?9)`)
|
|
609
|
+
.bind(repairActionId, incidentId, entityType, entityId, key, canonicalJson({ deadLetterId: item.id, state: "dead_letter" }), canonicalJson({ state: "pending", automaticAttemptsReset: true }), session.actor.id, timestamp),
|
|
610
|
+
db.prepare(`UPDATE operations_incidents SET state = 'acknowledged', acknowledged_by = ?2, acknowledged_at = COALESCE(acknowledged_at, ?3) WHERE id = ?1`).bind(incidentId, session.actor.id, timestamp),
|
|
611
|
+
db.prepare(`UPDATE dead_letter_items SET state = 'acknowledged' WHERE id = ?1`).bind(item.id),
|
|
612
|
+
operationsAudit(db, { key: `audit:${key}`, action: "operations.incident_retry_applied", incidentId, session, details: { repairActionId, entityType, entityId, jobType: item.jobType }, createdAt: timestamp }),
|
|
613
|
+
]);
|
|
614
|
+
return { incidentId, repairActionId, state: "applied", idempotentReplay: false };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function beginReconciliationRun(db: D1Database, kind: string, actorId: string): Promise<string> {
|
|
618
|
+
if (!/^[a-z][a-z0-9_]{1,63}$/u.test(kind)) throw new ApiError(400, "reconciliation_kind_invalid", "The reconciliation kind is invalid.");
|
|
619
|
+
const id = crypto.randomUUID();
|
|
620
|
+
await db.prepare(`INSERT INTO reconciliation_runs (id, installation_id, reconciliation_kind, state, cursor_json, started_at) VALUES (?1, 'default', ?2, 'running', ?3, ?4)`)
|
|
621
|
+
.bind(id, kind, canonicalJson({ actorId, limit: 100 }), now()).run();
|
|
622
|
+
return id;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export async function finishReconciliationRun(
|
|
626
|
+
db: D1Database,
|
|
627
|
+
runId: string,
|
|
628
|
+
input: { scanned: number; repaired: number; incidents?: number; errorCode?: string },
|
|
629
|
+
): Promise<void> {
|
|
630
|
+
const state = input.errorCode ? (input.repaired > 0 ? "partial" : "failed") : "succeeded";
|
|
631
|
+
await db.prepare(`UPDATE reconciliation_runs SET state = ?2, scanned_count = ?3, repaired_count = ?4, incident_count = ?5, error_code = ?6, finished_at = ?7 WHERE id = ?1 AND state = 'running'`)
|
|
632
|
+
.bind(runId, state, Math.max(0, input.scanned), Math.max(0, input.repaired), Math.max(0, input.incidents ?? 0), input.errorCode ?? null, now()).run();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function repairStaleOperationalLeases(
|
|
636
|
+
db: D1Database,
|
|
637
|
+
reconciliationRunId: string,
|
|
638
|
+
actorId: string,
|
|
639
|
+
limit = 100,
|
|
640
|
+
): Promise<{ scanned: number; repaired: number }> {
|
|
641
|
+
const timestamp = now();
|
|
642
|
+
const staleAt = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
643
|
+
const bounded = Math.max(1, Math.min(200, limit));
|
|
644
|
+
const [evidence, workflows, connections] = await Promise.all([
|
|
645
|
+
db.prepare(`SELECT id, updated_at AS marker FROM evidence_fetch_requests WHERE state = 'fetching' AND updated_at <= ?1 ORDER BY updated_at, id LIMIT ?2`)
|
|
646
|
+
.bind(staleAt, bounded).all<{ id: string; marker: string }>(),
|
|
647
|
+
db.prepare(`SELECT id, lease_expires_at AS marker FROM workflow_dispatch_outbox WHERE state = 'leased' AND lease_expires_at <= ?1 ORDER BY lease_expires_at, id LIMIT ?2`)
|
|
648
|
+
.bind(timestamp, bounded).all<{ id: string; marker: string }>(),
|
|
649
|
+
db.prepare(`SELECT id, expires_at AS marker FROM connection_execution_leases WHERE expires_at <= ?1 ORDER BY expires_at, id LIMIT ?2`)
|
|
650
|
+
.bind(timestamp, bounded).all<{ id: string; marker: string }>(),
|
|
651
|
+
]);
|
|
652
|
+
const statements: D1PreparedStatement[] = [];
|
|
653
|
+
for (const row of evidence.results) {
|
|
654
|
+
statements.push(
|
|
655
|
+
db.prepare(`UPDATE evidence_fetch_requests SET state = 'pending', next_attempt_at = ?2, last_error_code = 'stale_fetch_lease_recovered', updated_at = ?2 WHERE id = ?1 AND state = 'fetching' AND updated_at <= ?3`)
|
|
656
|
+
.bind(row.id, timestamp, staleAt),
|
|
657
|
+
db.prepare(`INSERT OR IGNORE INTO repair_actions (id, reconciliation_run_id, action_type, entity_type, entity_id, operation_key, state, before_json, after_json, planned_by, planned_at, applied_at) VALUES (?1, ?2, 'release_stale_lease', 'evidence_fetch_request', ?3, ?4, 'applied', ?5, ?6, ?7, ?8, ?8)`)
|
|
658
|
+
.bind(crypto.randomUUID(), reconciliationRunId, row.id, `stale-evidence:${row.id}:${row.marker}`, canonicalJson({ state: "fetching", updatedAt: row.marker }), canonicalJson({ state: "pending" }), actorId, timestamp),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
for (const row of workflows.results) {
|
|
662
|
+
statements.push(
|
|
663
|
+
db.prepare(`UPDATE workflow_dispatch_outbox SET state = 'failed', available_at = ?2, lease_expires_at = NULL, last_error_code = 'stale_dispatch_lease_recovered', updated_at = ?2 WHERE id = ?1 AND state = 'leased' AND lease_expires_at <= ?2`)
|
|
664
|
+
.bind(row.id, timestamp),
|
|
665
|
+
db.prepare(`INSERT OR IGNORE INTO repair_actions (id, reconciliation_run_id, action_type, entity_type, entity_id, operation_key, state, before_json, after_json, planned_by, planned_at, applied_at) VALUES (?1, ?2, 'release_stale_lease', 'workflow_dispatch_outbox', ?3, ?4, 'applied', ?5, ?6, ?7, ?8, ?8)`)
|
|
666
|
+
.bind(crypto.randomUUID(), reconciliationRunId, row.id, `stale-workflow:${row.id}:${row.marker}`, canonicalJson({ state: "leased", leaseExpiresAt: row.marker }), canonicalJson({ state: "failed" }), actorId, timestamp),
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
for (const row of connections.results) {
|
|
670
|
+
statements.push(
|
|
671
|
+
db.prepare(`DELETE FROM connection_execution_leases WHERE id = ?1 AND expires_at <= ?2`).bind(row.id, timestamp),
|
|
672
|
+
db.prepare(`INSERT OR IGNORE INTO repair_actions (id, reconciliation_run_id, action_type, entity_type, entity_id, operation_key, state, before_json, after_json, planned_by, planned_at, applied_at) VALUES (?1, ?2, 'release_stale_lease', 'connection_execution_lease', ?3, ?4, 'applied', ?5, ?6, ?7, ?8, ?8)`)
|
|
673
|
+
.bind(crypto.randomUUID(), reconciliationRunId, row.id, `stale-connection:${row.id}:${row.marker}`, canonicalJson({ expiresAt: row.marker }), canonicalJson({ deleted: true }), actorId, timestamp),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
if (statements.length) await db.batch(statements);
|
|
677
|
+
const repaired = evidence.results.length + workflows.results.length + connections.results.length;
|
|
678
|
+
return { scanned: repaired, repaired };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export async function listDeadLetters(db: D1Database, limit = 100): Promise<Record<string, unknown>[]> {
|
|
682
|
+
const rows = await db.prepare(`SELECT * FROM dead_letter_items ORDER BY CASE state WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END, last_seen_at DESC, id DESC LIMIT ?1`)
|
|
683
|
+
.bind(Math.max(1, Math.min(200, limit))).all<Record<string, unknown>>();
|
|
684
|
+
return rows.results;
|
|
685
|
+
}
|