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,278 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { sha256 } from "./report-crypto";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { OperatorSession } from "./report-types";
|
|
5
|
+
|
|
6
|
+
interface ShadowCandidate {
|
|
7
|
+
shadowRunId: string;
|
|
8
|
+
reportId: string;
|
|
9
|
+
primaryRunId: string | null;
|
|
10
|
+
primaryState: string | null;
|
|
11
|
+
decisionId: string | null;
|
|
12
|
+
decisionCode: string | null;
|
|
13
|
+
policyCode: string | null;
|
|
14
|
+
findingId: string | null;
|
|
15
|
+
findingDataJson: string | null;
|
|
16
|
+
findingStepRunId: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function now(): string { return new Date().toISOString(); }
|
|
20
|
+
|
|
21
|
+
function parsedObject(value: string | null): Record<string, unknown> {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = value ? JSON.parse(value) : {};
|
|
24
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function recommendationValues(data: Record<string, unknown>): string[] {
|
|
31
|
+
return [data.decision_code, data.policy_code, data.result_code, data.outcome, data.conclusion, data.classification]
|
|
32
|
+
.filter((value): value is string => typeof value === "string")
|
|
33
|
+
.map((value) => value.trim().toLowerCase())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function qualityQueue(db: D1Database): Promise<{ id: string } | null> {
|
|
38
|
+
return db.prepare(`SELECT id FROM queue_definitions WHERE purpose = 'quality' AND status = 'active' ORDER BY position, id LIMIT 1`)
|
|
39
|
+
.first<{ id: string }>();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function insertQualityTask(
|
|
43
|
+
db: D1Database,
|
|
44
|
+
input: {
|
|
45
|
+
comparisonId: string;
|
|
46
|
+
reportId: string;
|
|
47
|
+
shadowRunId: string;
|
|
48
|
+
stepRunId: string;
|
|
49
|
+
queueId: string;
|
|
50
|
+
codes: string[];
|
|
51
|
+
severity: string;
|
|
52
|
+
actorId: string;
|
|
53
|
+
createdAt: string;
|
|
54
|
+
},
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const taskId = `${input.comparisonId}:quality-task`;
|
|
57
|
+
const context = {
|
|
58
|
+
schema_version: "1",
|
|
59
|
+
comparison_id: input.comparisonId,
|
|
60
|
+
shadow_run_id: input.shadowRunId,
|
|
61
|
+
comparison_codes: input.codes,
|
|
62
|
+
severity: input.severity,
|
|
63
|
+
purpose: "Review a non-authoritative shadow recommendation against the authoritative decision.",
|
|
64
|
+
};
|
|
65
|
+
const contextJson = canonicalJson(context);
|
|
66
|
+
const contextDigest = `sha256:${await sha256(contextJson)}`;
|
|
67
|
+
await db.batch([
|
|
68
|
+
db.prepare(`
|
|
69
|
+
INSERT OR IGNORE INTO human_tasks (
|
|
70
|
+
id, installation_id, report_id, run_id, step_run_id, task_type,
|
|
71
|
+
state, queue_id, context_json, context_digest, response_schema_json,
|
|
72
|
+
due_at, escalation_at, created_at, updated_at
|
|
73
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, 'quality_review', 'open', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
|
|
74
|
+
`).bind(
|
|
75
|
+
taskId, input.reportId, input.shadowRunId, input.stepRunId, input.queueId,
|
|
76
|
+
contextJson, contextDigest,
|
|
77
|
+
canonicalJson({
|
|
78
|
+
type: "object",
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
required: ["outcome", "notes"],
|
|
81
|
+
properties: {
|
|
82
|
+
outcome: { type: "string", enum: ["agree_primary", "agree_shadow", "inconclusive"] },
|
|
83
|
+
notes: { type: "string", minLength: 10, maxLength: 4000 },
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
new Date(Date.now() + 24 * 60 * 60_000).toISOString(),
|
|
87
|
+
new Date(Date.now() + 12 * 60 * 60_000).toISOString(), input.createdAt,
|
|
88
|
+
),
|
|
89
|
+
db.prepare(`UPDATE shadow_comparisons SET status = 'quality_task_created' WHERE id = ?1 AND status = 'pending'`).bind(input.comparisonId),
|
|
90
|
+
db.prepare(`
|
|
91
|
+
INSERT OR IGNORE INTO audit_events (
|
|
92
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
93
|
+
target_type, target_id, details_json, created_at
|
|
94
|
+
) VALUES (?1, ?2, 'shadow.quality_task_created', 'system', ?3,
|
|
95
|
+
'shadow_comparison', ?4, ?5, ?6)
|
|
96
|
+
`).bind(
|
|
97
|
+
crypto.randomUUID(), `shadow-quality-task:${input.comparisonId}`,
|
|
98
|
+
input.actorId, input.comparisonId, canonicalJson({ taskId, queueId: input.queueId }), input.createdAt,
|
|
99
|
+
),
|
|
100
|
+
]);
|
|
101
|
+
return taskId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function compareCompletedShadowRuns(db: D1Database, limit = 50): Promise<{ compared: number; tasksCreated: number }> {
|
|
105
|
+
const rows = await db.prepare(`
|
|
106
|
+
SELECT shadow.id AS shadowRunId, shadow.report_id AS reportId,
|
|
107
|
+
primary_run.id AS primaryRunId, primary_run.state AS primaryState,
|
|
108
|
+
decision.id AS decisionId, decision.decision_code AS decisionCode,
|
|
109
|
+
decision.policy_code AS policyCode, finding.id AS findingId,
|
|
110
|
+
finding.data_json AS findingDataJson, finding.step_run_id AS findingStepRunId
|
|
111
|
+
FROM workflow_runs shadow
|
|
112
|
+
LEFT JOIN workflow_runs primary_run ON primary_run.id = (
|
|
113
|
+
SELECT candidate.id FROM workflow_runs candidate
|
|
114
|
+
WHERE candidate.report_id = shadow.report_id AND candidate.run_kind = 'primary'
|
|
115
|
+
ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT 1
|
|
116
|
+
)
|
|
117
|
+
LEFT JOIN decisions decision ON decision.id = (
|
|
118
|
+
SELECT candidate.id FROM decisions candidate WHERE candidate.report_id = shadow.report_id
|
|
119
|
+
ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT 1
|
|
120
|
+
)
|
|
121
|
+
LEFT JOIN findings finding ON finding.id = (
|
|
122
|
+
SELECT candidate.id FROM findings candidate
|
|
123
|
+
WHERE candidate.run_id = shadow.id AND candidate.status = 'available'
|
|
124
|
+
ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT 1
|
|
125
|
+
)
|
|
126
|
+
WHERE shadow.run_kind = 'shadow' AND shadow.state = 'succeeded'
|
|
127
|
+
AND NOT EXISTS (SELECT 1 FROM shadow_comparisons comparison WHERE comparison.shadow_run_id = shadow.id)
|
|
128
|
+
AND (decision.id IS NOT NULL OR primary_run.state IN ('succeeded','failed','terminated','superseded'))
|
|
129
|
+
ORDER BY shadow.finished_at, shadow.id LIMIT ?1
|
|
130
|
+
`).bind(Math.max(1, Math.min(100, limit))).all<ShadowCandidate>();
|
|
131
|
+
const queue = await qualityQueue(db);
|
|
132
|
+
let compared = 0;
|
|
133
|
+
let tasksCreated = 0;
|
|
134
|
+
for (const row of rows.results) {
|
|
135
|
+
const data = parsedObject(row.findingDataJson);
|
|
136
|
+
const recommendations = recommendationValues(data);
|
|
137
|
+
const expected = [row.decisionCode, row.policyCode]
|
|
138
|
+
.filter((value): value is string => typeof value === "string")
|
|
139
|
+
.map((value) => value.toLowerCase());
|
|
140
|
+
let codes: string[];
|
|
141
|
+
let severity: "info" | "low" | "medium" | "high";
|
|
142
|
+
if (!row.decisionId) {
|
|
143
|
+
codes = ["authoritative_decision_missing"];
|
|
144
|
+
severity = "medium";
|
|
145
|
+
} else if (!row.findingId || recommendations.length === 0) {
|
|
146
|
+
codes = ["shadow_recommendation_missing"];
|
|
147
|
+
severity = "low";
|
|
148
|
+
} else if (expected.some((value) => recommendations.includes(value))) {
|
|
149
|
+
codes = ["shadow_agrees_with_authoritative_decision"];
|
|
150
|
+
severity = "info";
|
|
151
|
+
} else {
|
|
152
|
+
codes = ["shadow_decision_disagreement"];
|
|
153
|
+
severity = "high";
|
|
154
|
+
}
|
|
155
|
+
const comparisonId = `${row.shadowRunId}:comparison`;
|
|
156
|
+
const timestamp = now();
|
|
157
|
+
await db.batch([
|
|
158
|
+
db.prepare(`
|
|
159
|
+
INSERT OR IGNORE INTO shadow_comparisons (
|
|
160
|
+
id, report_id, primary_run_id, shadow_run_id, primary_decision_id,
|
|
161
|
+
shadow_finding_id, comparison_codes_json, severity, status, created_at
|
|
162
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9)
|
|
163
|
+
`).bind(
|
|
164
|
+
comparisonId, row.reportId, row.primaryRunId, row.shadowRunId, row.decisionId,
|
|
165
|
+
row.findingId, canonicalJson(codes), severity, timestamp,
|
|
166
|
+
),
|
|
167
|
+
db.prepare(`
|
|
168
|
+
INSERT OR IGNORE INTO audit_events (
|
|
169
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
170
|
+
target_type, target_id, details_json, created_at
|
|
171
|
+
) VALUES (?1, ?2, 'shadow.comparison_created', 'system', 'shadow-quality-controller',
|
|
172
|
+
'shadow_comparison', ?3, ?4, ?5)
|
|
173
|
+
`).bind(
|
|
174
|
+
crypto.randomUUID(), `shadow-comparison:${row.shadowRunId}`, comparisonId,
|
|
175
|
+
canonicalJson({ reportId: row.reportId, shadowRunId: row.shadowRunId, codes, severity }), timestamp,
|
|
176
|
+
),
|
|
177
|
+
]);
|
|
178
|
+
compared += 1;
|
|
179
|
+
if ((severity === "high" || severity === "medium") && queue && row.findingStepRunId) {
|
|
180
|
+
await insertQualityTask(db, {
|
|
181
|
+
comparisonId, reportId: row.reportId, shadowRunId: row.shadowRunId,
|
|
182
|
+
stepRunId: row.findingStepRunId, queueId: queue.id, codes, severity,
|
|
183
|
+
actorId: "shadow-quality-controller", createdAt: timestamp,
|
|
184
|
+
});
|
|
185
|
+
tasksCreated += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { compared, tasksCreated };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function listShadowComparisons(
|
|
192
|
+
db: D1Database,
|
|
193
|
+
input: { status?: string | null; limit?: number; allowedQueueIds?: string[] | null },
|
|
194
|
+
): Promise<Record<string, unknown>[]> {
|
|
195
|
+
const rows = await db.prepare(`
|
|
196
|
+
SELECT comparison.id, comparison.report_id AS reportId,
|
|
197
|
+
report.public_reference AS reportReference,
|
|
198
|
+
comparison.primary_run_id AS primaryRunId, comparison.shadow_run_id AS shadowRunId,
|
|
199
|
+
comparison.primary_decision_id AS primaryDecisionId,
|
|
200
|
+
comparison.shadow_finding_id AS shadowFindingId,
|
|
201
|
+
comparison.comparison_codes_json AS comparisonCodesJson,
|
|
202
|
+
comparison.severity, comparison.status, comparison.created_at AS createdAt,
|
|
203
|
+
comparison.reviewed_at AS reviewedAt
|
|
204
|
+
FROM shadow_comparisons comparison JOIN reports report ON report.id = comparison.report_id
|
|
205
|
+
WHERE (?1 IS NULL OR comparison.status = ?1)
|
|
206
|
+
AND (?3 = 1 OR report.queue_id IN (SELECT value FROM json_each(?4)))
|
|
207
|
+
ORDER BY CASE comparison.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END,
|
|
208
|
+
comparison.created_at DESC, comparison.id DESC LIMIT ?2
|
|
209
|
+
`).bind(
|
|
210
|
+
input.status ?? null,
|
|
211
|
+
Math.max(1, Math.min(100, input.limit ?? 50)),
|
|
212
|
+
input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
|
|
213
|
+
canonicalJson(input.allowedQueueIds ?? []),
|
|
214
|
+
).all<Record<string, unknown>>();
|
|
215
|
+
return rows.results.map((row) => ({
|
|
216
|
+
...row,
|
|
217
|
+
comparisonCodes: typeof row.comparisonCodesJson === "string" ? JSON.parse(row.comparisonCodesJson) : [],
|
|
218
|
+
comparisonCodesJson: undefined,
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function createShadowQualityTask(
|
|
223
|
+
db: D1Database,
|
|
224
|
+
comparisonId: string,
|
|
225
|
+
session: OperatorSession,
|
|
226
|
+
idempotencyKey: string,
|
|
227
|
+
): Promise<{ comparisonId: string; taskId: string; idempotentReplay: boolean }> {
|
|
228
|
+
const existing = await db.prepare(`SELECT id FROM human_tasks WHERE id = ?1 LIMIT 1`).bind(`${comparisonId}:quality-task`).first();
|
|
229
|
+
if (existing) return { comparisonId, taskId: `${comparisonId}:quality-task`, idempotentReplay: true };
|
|
230
|
+
const row = await db.prepare(`
|
|
231
|
+
SELECT comparison.report_id AS reportId, comparison.shadow_run_id AS shadowRunId,
|
|
232
|
+
comparison.comparison_codes_json AS codesJson, comparison.severity,
|
|
233
|
+
COALESCE(finding.step_run_id, (
|
|
234
|
+
SELECT step.id FROM workflow_step_runs step WHERE step.run_id = comparison.shadow_run_id
|
|
235
|
+
ORDER BY step.finished_at DESC, step.id DESC LIMIT 1
|
|
236
|
+
)) AS stepRunId
|
|
237
|
+
FROM shadow_comparisons comparison
|
|
238
|
+
LEFT JOIN findings finding ON finding.id = comparison.shadow_finding_id
|
|
239
|
+
WHERE comparison.id = ?1 AND comparison.status IN ('pending','quality_task_created') LIMIT 1
|
|
240
|
+
`).bind(comparisonId).first<{ reportId: string; shadowRunId: string; codesJson: string; severity: string; stepRunId: string | null }>();
|
|
241
|
+
if (!row) throw new ApiError(404, "shadow_comparison_not_found", "The pending shadow comparison does not exist.");
|
|
242
|
+
if (!row.stepRunId) throw new ApiError(409, "shadow_comparison_step_unavailable", "The shadow run has no step to anchor a quality task.");
|
|
243
|
+
const queue = await qualityQueue(db);
|
|
244
|
+
if (!queue) throw new ApiError(409, "quality_queue_unavailable", "No active quality queue is configured.");
|
|
245
|
+
const taskId = await insertQualityTask(db, {
|
|
246
|
+
comparisonId, reportId: row.reportId, shadowRunId: row.shadowRunId,
|
|
247
|
+
stepRunId: row.stepRunId, queueId: queue.id,
|
|
248
|
+
codes: JSON.parse(row.codesJson) as string[], severity: row.severity,
|
|
249
|
+
actorId: session.actor.id, createdAt: now(),
|
|
250
|
+
});
|
|
251
|
+
await db.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'shadow.quality_task_requested', ?3, ?4, 'shadow_comparison', ?5, ?6, ?7)`)
|
|
252
|
+
.bind(crypto.randomUUID(), `shadow-quality-request:${idempotencyKey}`, session.actor.type, session.actor.id, comparisonId, canonicalJson({ taskId }), now()).run();
|
|
253
|
+
return { comparisonId, taskId, idempotentReplay: false };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function dismissShadowComparison(
|
|
257
|
+
db: D1Database,
|
|
258
|
+
comparisonId: string,
|
|
259
|
+
reason: string,
|
|
260
|
+
session: OperatorSession,
|
|
261
|
+
idempotencyKey: string,
|
|
262
|
+
): Promise<{ comparisonId: string; status: string; idempotentReplay: boolean }> {
|
|
263
|
+
if (reason.trim().length < 5 || reason.length > 2_000) throw new ApiError(400, "shadow_dismissal_reason_invalid", "A dismissal reason is required.");
|
|
264
|
+
const key = `shadow-dismiss:${idempotencyKey}`;
|
|
265
|
+
const prior = await db.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 AND action = 'shadow.comparison_dismissed' LIMIT 1`)
|
|
266
|
+
.bind(key).first<{ targetId: string }>();
|
|
267
|
+
if (prior) {
|
|
268
|
+
if (prior.targetId !== comparisonId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another comparison.");
|
|
269
|
+
return { comparisonId, status: "dismissed", idempotentReplay: true };
|
|
270
|
+
}
|
|
271
|
+
const timestamp = now();
|
|
272
|
+
const changed = await db.prepare(`UPDATE shadow_comparisons SET status = 'dismissed', reviewed_at = ?2 WHERE id = ?1 AND status IN ('pending','quality_task_created')`)
|
|
273
|
+
.bind(comparisonId, timestamp).run();
|
|
274
|
+
if (changed.meta.changes !== 1) throw new ApiError(404, "shadow_comparison_not_pending", "The pending shadow comparison does not exist.");
|
|
275
|
+
await db.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'shadow.comparison_dismissed', ?3, ?4, 'shadow_comparison', ?5, ?6, ?7)`)
|
|
276
|
+
.bind(crypto.randomUUID(), key, session.actor.type, session.actor.id, comparisonId, canonicalJson({ reason: reason.trim() }), timestamp).run();
|
|
277
|
+
return { comparisonId, status: "dismissed", idempotentReplay: false };
|
|
278
|
+
}
|