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,554 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
3
|
+
import { executeAssistantReadOnlyComponent } from "./component-executor";
|
|
4
|
+
import { validateConfiguration, type ConfigurationType } from "./configuration-registry";
|
|
5
|
+
import { assertOperatorQueueAccess, projectOperatorFields } from "./report-auth";
|
|
6
|
+
import { sha256 } from "./report-crypto";
|
|
7
|
+
import { ApiError } from "./report-http";
|
|
8
|
+
import type { OperatorPermissions, OperatorSession } from "./report-types";
|
|
9
|
+
import type { JsonObject } from "./workflow-platform-types";
|
|
10
|
+
import { parseWorkflowGraph, validateWorkflowGraph } from "./workflow-platform-validation";
|
|
11
|
+
|
|
12
|
+
interface AssistantSessionRow {
|
|
13
|
+
id: string;
|
|
14
|
+
principalId: string;
|
|
15
|
+
status: string;
|
|
16
|
+
timezone: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AssistantAnswer {
|
|
21
|
+
toolName: string;
|
|
22
|
+
toolInput: Record<string, unknown>;
|
|
23
|
+
toolOutput: Record<string, unknown>;
|
|
24
|
+
body: string;
|
|
25
|
+
citations: Record<string, unknown>[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function now(): string {
|
|
29
|
+
return new Date().toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function permissionSnapshot(session: OperatorSession): Record<string, unknown> {
|
|
33
|
+
return {
|
|
34
|
+
actor_id: session.actor.id,
|
|
35
|
+
roles: session.roles,
|
|
36
|
+
permissions: Object.fromEntries(Object.entries(session.permissions).filter(([, enabled]) => enabled)),
|
|
37
|
+
queue_scope: session.queueScope ?? null,
|
|
38
|
+
field_scope: session.fieldScope ?? null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requirePermission(session: OperatorSession, permission: keyof OperatorPermissions): void {
|
|
43
|
+
if (!session.permissions[permission]) throw new ApiError(403, "assistant_tool_permission_denied", "This assistant tool is not available to your role.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function localParts(date: Date, timezone: string): { year: number; month: number; day: number; hour: number; minute: number; second: number } {
|
|
47
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
48
|
+
try {
|
|
49
|
+
parts = new Intl.DateTimeFormat("en-CA", {
|
|
50
|
+
timeZone: timezone,
|
|
51
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
52
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23",
|
|
53
|
+
}).formatToParts(date);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new ApiError(409, "assistant_timezone_invalid", "The installation timezone is invalid.");
|
|
56
|
+
}
|
|
57
|
+
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
|
|
58
|
+
return { year: value("year"), month: value("month"), day: value("day"), hour: value("hour"), minute: value("minute"), second: value("second") };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function zonedDateToUtc(parts: { year: number; month: number; day: number; hour?: number }, timezone: string): Date {
|
|
62
|
+
const desired = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour ?? 0, 0, 0, 0);
|
|
63
|
+
let guess = desired;
|
|
64
|
+
for (let index = 0; index < 3; index += 1) {
|
|
65
|
+
const current = localParts(new Date(guess), timezone);
|
|
66
|
+
const represented = Date.UTC(current.year, current.month - 1, current.day, current.hour, current.minute, current.second);
|
|
67
|
+
guess += desired - represented;
|
|
68
|
+
}
|
|
69
|
+
return new Date(guess);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dayWindow(timezone: string, offsetDays: number): { from: string; to: string; label: string } {
|
|
73
|
+
const current = localParts(new Date(), timezone);
|
|
74
|
+
const date = new Date(Date.UTC(current.year, current.month - 1, current.day + offsetDays));
|
|
75
|
+
const year = date.getUTCFullYear();
|
|
76
|
+
const month = date.getUTCMonth() + 1;
|
|
77
|
+
const day = date.getUTCDate();
|
|
78
|
+
const next = new Date(Date.UTC(year, month - 1, day + 1));
|
|
79
|
+
return {
|
|
80
|
+
from: zonedDateToUtc({ year, month, day }, timezone).toISOString(),
|
|
81
|
+
to: zonedDateToUtc({ year: next.getUTCFullYear(), month: next.getUTCMonth() + 1, day: next.getUTCDate() }, timezone).toISOString(),
|
|
82
|
+
label: `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadAssistantSession(db: D1Database, sessionId: string, principalId: string): Promise<AssistantSessionRow> {
|
|
87
|
+
const row = await db.prepare(`
|
|
88
|
+
SELECT id, principal_id AS principalId, status, timezone, expires_at AS expiresAt
|
|
89
|
+
FROM assistant_sessions WHERE id = ?1 AND principal_id = ?2 LIMIT 1
|
|
90
|
+
`).bind(sessionId, principalId).first<AssistantSessionRow>();
|
|
91
|
+
if (!row) throw new ApiError(404, "assistant_session_not_found", "The assistant session does not exist.");
|
|
92
|
+
if (row.status !== "active" || row.expiresAt <= now()) throw new ApiError(409, "assistant_session_inactive", "The assistant session is no longer active.");
|
|
93
|
+
return row;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function createAssistantSession(
|
|
97
|
+
db: D1Database,
|
|
98
|
+
session: OperatorSession,
|
|
99
|
+
idempotencyKey: string,
|
|
100
|
+
): Promise<{ sessionId: string; timezone: string; expiresAt: string; idempotentReplay: boolean }> {
|
|
101
|
+
const existing = await db.prepare(`
|
|
102
|
+
SELECT id, timezone, expires_at AS expiresAt FROM assistant_sessions
|
|
103
|
+
WHERE principal_id = ?1 AND idempotency_key = ?2 LIMIT 1
|
|
104
|
+
`).bind(session.actor.id, idempotencyKey).first<{ id: string; timezone: string; expiresAt: string }>();
|
|
105
|
+
if (existing) return { sessionId: existing.id, timezone: existing.timezone, expiresAt: existing.expiresAt, idempotentReplay: true };
|
|
106
|
+
const installation = await db.prepare(`SELECT timezone FROM installations WHERE id = 'default' LIMIT 1`).first<{ timezone: string }>();
|
|
107
|
+
const timezone = installation?.timezone ?? "UTC";
|
|
108
|
+
localParts(new Date(), timezone);
|
|
109
|
+
const sessionId = crypto.randomUUID();
|
|
110
|
+
const createdAt = now();
|
|
111
|
+
const expiresAt = new Date(Date.now() + 8 * 3_600_000).toISOString();
|
|
112
|
+
await db.prepare(`
|
|
113
|
+
INSERT INTO assistant_sessions (
|
|
114
|
+
id, installation_id, principal_id, status, timezone,
|
|
115
|
+
created_at, updated_at, expires_at, idempotency_key
|
|
116
|
+
) VALUES (?1, 'default', ?2, 'active', ?3, ?4, ?4, ?5, ?6)
|
|
117
|
+
`).bind(sessionId, session.actor.id, timezone, createdAt, expiresAt, idempotencyKey).run();
|
|
118
|
+
return { sessionId, timezone, expiresAt, idempotentReplay: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function loadAssistantHistory(
|
|
122
|
+
db: D1Database,
|
|
123
|
+
sessionId: string,
|
|
124
|
+
session: OperatorSession,
|
|
125
|
+
): Promise<Record<string, unknown>> {
|
|
126
|
+
const assistantSession = await db.prepare(`
|
|
127
|
+
SELECT id, principal_id AS principalId, status, timezone,
|
|
128
|
+
created_at AS createdAt, updated_at AS updatedAt, expires_at AS expiresAt
|
|
129
|
+
FROM assistant_sessions WHERE id = ?1 AND principal_id = ?2 LIMIT 1
|
|
130
|
+
`).bind(sessionId, session.actor.id).first<Record<string, unknown>>();
|
|
131
|
+
if (!assistantSession) throw new ApiError(404, "assistant_session_not_found", "The assistant session does not exist.");
|
|
132
|
+
const [messages, tools] = await Promise.all([
|
|
133
|
+
db.prepare(`SELECT id, role, body, citations_json AS citationsJson, created_at AS createdAt FROM assistant_messages WHERE session_id = ?1 ORDER BY created_at, id LIMIT 500`)
|
|
134
|
+
.bind(sessionId).all<Record<string, unknown>>(),
|
|
135
|
+
db.prepare(`SELECT id, message_id AS messageId, tool_name AS toolName, input_json AS inputJson, output_summary_json AS outputSummaryJson, status, error_code AS errorCode, started_at AS startedAt, finished_at AS finishedAt FROM assistant_tool_calls WHERE session_id = ?1 ORDER BY started_at, id LIMIT 500`)
|
|
136
|
+
.bind(sessionId).all<Record<string, unknown>>(),
|
|
137
|
+
]);
|
|
138
|
+
return {
|
|
139
|
+
session: assistantSession,
|
|
140
|
+
messages: messages.results.map((message) => ({ ...message, citations: JSON.parse(String(message.citationsJson ?? "[]")), citationsJson: undefined })),
|
|
141
|
+
toolCalls: tools.results.map((tool) => ({
|
|
142
|
+
...tool,
|
|
143
|
+
input: tool.inputJson ? JSON.parse(String(tool.inputJson)) : null,
|
|
144
|
+
outputSummary: tool.outputSummaryJson ? JSON.parse(String(tool.outputSummaryJson)) : null,
|
|
145
|
+
inputJson: undefined,
|
|
146
|
+
outputSummaryJson: undefined,
|
|
147
|
+
})),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function metricsAnswer(
|
|
152
|
+
db: D1Database,
|
|
153
|
+
prompt: string,
|
|
154
|
+
timezone: string,
|
|
155
|
+
session: OperatorSession,
|
|
156
|
+
): Promise<AssistantAnswer> {
|
|
157
|
+
requirePermission(session, "readAnalytics");
|
|
158
|
+
const lowered = prompt.toLocaleLowerCase("en-US");
|
|
159
|
+
const window = lowered.includes("yesterday") ? dayWindow(timezone, -1) : lowered.includes("today") ? dayWindow(timezone, 0) : dayWindow(timezone, -1);
|
|
160
|
+
const phishing = /phish(?:ing)?|phising/u.test(lowered);
|
|
161
|
+
const resolved = /resolved|closed|actioned/u.test(lowered);
|
|
162
|
+
const row = await db.prepare(`
|
|
163
|
+
SELECT COUNT(*) AS count FROM reports
|
|
164
|
+
WHERE (?1 = 0 OR lower(reason_code) LIKE '%phish%')
|
|
165
|
+
AND (?2 = 0 OR state IN ('resolved_by_human','resolved_by_ai','closed'))
|
|
166
|
+
AND COALESCE(resolved_at, updated_at) >= ?3 AND COALESCE(resolved_at, updated_at) < ?4
|
|
167
|
+
AND (?5 = 1 OR queue_id IN (SELECT value FROM json_each(?6)))
|
|
168
|
+
`).bind(
|
|
169
|
+
phishing ? 1 : 0, resolved ? 1 : 0, window.from, window.to,
|
|
170
|
+
session.queueScope === null || session.queueScope === undefined ? 1 : 0,
|
|
171
|
+
canonicalJson(session.queueScope ?? []),
|
|
172
|
+
).first<{ count: number }>();
|
|
173
|
+
const filters = { phishing, resolved, from: window.from, to: window.to, timezone, localDate: window.label, queueScope: session.queueScope ?? null };
|
|
174
|
+
const count = Number(row?.count ?? 0);
|
|
175
|
+
return {
|
|
176
|
+
toolName: "query_metrics",
|
|
177
|
+
toolInput: filters,
|
|
178
|
+
toolOutput: { count, filters },
|
|
179
|
+
body: `${count} report${count === 1 ? "" : "s"} matched for ${window.label} (${timezone}).`,
|
|
180
|
+
citations: [{ type: "metric_window", source: "reports", ...filters }],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function findReportId(db: D1Database, prompt: string, explicit?: string): Promise<string | null> {
|
|
185
|
+
if (explicit) {
|
|
186
|
+
const row = await db.prepare(`SELECT id FROM reports WHERE id = ?1 OR public_reference = ?1 LIMIT 1`).bind(explicit).first<{ id: string }>();
|
|
187
|
+
return row?.id ?? null;
|
|
188
|
+
}
|
|
189
|
+
const candidates = prompt.match(/[A-Za-z0-9][A-Za-z0-9_-]{5,99}/gu) ?? [];
|
|
190
|
+
for (const candidate of candidates.slice(-10).reverse()) {
|
|
191
|
+
const row = await db.prepare(`SELECT id FROM reports WHERE id = ?1 OR public_reference = ?1 LIMIT 1`).bind(candidate).first<{ id: string }>();
|
|
192
|
+
if (row) return row.id;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function explainAnswer(
|
|
198
|
+
db: D1Database,
|
|
199
|
+
prompt: string,
|
|
200
|
+
explicitReport: string | undefined,
|
|
201
|
+
session: OperatorSession,
|
|
202
|
+
): Promise<AssistantAnswer> {
|
|
203
|
+
requirePermission(session, "readReports");
|
|
204
|
+
requirePermission(session, "readEvidence");
|
|
205
|
+
const reportId = await findReportId(db, prompt, explicitReport);
|
|
206
|
+
if (!reportId) throw new ApiError(400, "assistant_report_reference_required", "Include a report ID or public reference to explain.");
|
|
207
|
+
const report = await db.prepare(`SELECT id, public_reference AS publicReference, queue_id AS queueId, state, reason_code AS reasonCode, policy_version_id AS policyVersionId FROM reports WHERE id = ?1 LIMIT 1`)
|
|
208
|
+
.bind(reportId).first<{ id: string; publicReference: string; queueId: string | null; state: string; reasonCode: string; policyVersionId: string | null }>();
|
|
209
|
+
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
210
|
+
assertOperatorQueueAccess(session, report.queueId);
|
|
211
|
+
const [decision, findings, actions, runs] = await Promise.all([
|
|
212
|
+
db.prepare(`SELECT id, decision_code AS decisionCode, policy_version_id AS policyVersionId, policy_code AS policyCode, rationale, evidence_references_json AS evidenceReferencesJson, maker_type AS makerType, maker_id AS makerId, created_at AS createdAt FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1`).bind(report.id).first<Record<string, unknown>>(),
|
|
213
|
+
db.prepare(`SELECT id, finding_type AS findingType, status, confidence, summary, component_version_id AS componentVersionId, output_digest AS outputDigest, created_at AS createdAt FROM findings WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 20`).bind(report.id).all<Record<string, unknown>>(),
|
|
214
|
+
db.prepare(`SELECT id, action_code AS actionCode, state, payload_digest AS payloadDigest, component_version_id AS componentVersionId, created_at AS createdAt FROM action_proposals WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 20`).bind(report.id).all<Record<string, unknown>>(),
|
|
215
|
+
db.prepare(`SELECT id, workflow_version_id AS workflowVersionId, authority_mode AS authorityMode, state, created_at AS createdAt, finished_at AS finishedAt FROM workflow_runs WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 20`).bind(report.id).all<Record<string, unknown>>(),
|
|
216
|
+
]);
|
|
217
|
+
const explanation = decision
|
|
218
|
+
? `Report ${report.publicReference} is ${report.state}. The latest recorded decision was ${String(decision.decisionCode)} under policy ${String(decision.policyCode)}: ${String(decision.rationale)}`
|
|
219
|
+
: `Report ${report.publicReference} is ${report.state}. No immutable decision has been recorded yet.`;
|
|
220
|
+
const citations = [
|
|
221
|
+
{ type: "report", id: report.id, reference: report.publicReference },
|
|
222
|
+
...(decision ? [{ type: "decision", id: decision.id, policy_version_id: decision.policyVersionId }] : []),
|
|
223
|
+
...findings.results.map((finding) => ({ type: "finding", id: finding.id, component_version_id: finding.componentVersionId, output_digest: finding.outputDigest })),
|
|
224
|
+
...actions.results.map((action) => ({ type: "action_proposal", id: action.id, component_version_id: action.componentVersionId, payload_digest: action.payloadDigest })),
|
|
225
|
+
...runs.results.map((run) => ({ type: "workflow_run", id: run.id, workflow_version_id: run.workflowVersionId })),
|
|
226
|
+
];
|
|
227
|
+
return {
|
|
228
|
+
toolName: "get_decision_trace",
|
|
229
|
+
toolInput: { reportId: report.id },
|
|
230
|
+
toolOutput: projectOperatorFields(session, "assistant_report_trace", { report, decision, findings: findings.results, actions: actions.results, runs: runs.results }),
|
|
231
|
+
body: explanation,
|
|
232
|
+
citations,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function searchAnswer(db: D1Database, prompt: string, session: OperatorSession): Promise<AssistantAnswer> {
|
|
237
|
+
requirePermission(session, "searchWorkspace");
|
|
238
|
+
const term = prompt.replace(/^(?:find|search|show)(?:\s+me)?(?:\s+reports?)?\s*/iu, "").trim().slice(0, 100);
|
|
239
|
+
if (term.length < 2) throw new ApiError(400, "assistant_search_term_required", "Include at least two characters to search reports.");
|
|
240
|
+
const pattern = `%${term.replace(/[%_]/gu, "")} %`.replace(/ %$/u, "%");
|
|
241
|
+
const result = await db.prepare(`
|
|
242
|
+
SELECT DISTINCT r.id, r.public_reference AS publicReference, r.state,
|
|
243
|
+
r.reason_code AS reasonCode, r.queue_id AS queueId, r.received_at AS receivedAt
|
|
244
|
+
FROM reports r LEFT JOIN report_targets t ON t.report_id = r.id
|
|
245
|
+
WHERE (lower(r.public_reference) LIKE lower(?1) OR lower(r.reason_code) LIKE lower(?1)
|
|
246
|
+
OR lower(COALESCE(t.target_reference, '')) LIKE lower(?1))
|
|
247
|
+
AND (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3)))
|
|
248
|
+
ORDER BY r.received_at DESC, r.id DESC LIMIT 20
|
|
249
|
+
`).bind(
|
|
250
|
+
pattern,
|
|
251
|
+
session.queueScope === null || session.queueScope === undefined ? 1 : 0,
|
|
252
|
+
canonicalJson(session.queueScope ?? []),
|
|
253
|
+
).all<Record<string, unknown>>();
|
|
254
|
+
const reports = projectOperatorFields(session, "reports", result.results);
|
|
255
|
+
return {
|
|
256
|
+
toolName: "search_reports",
|
|
257
|
+
toolInput: { term, limit: 20, queueScope: session.queueScope ?? null },
|
|
258
|
+
toolOutput: { reports },
|
|
259
|
+
body: `I found ${result.results.length} accessible report${result.results.length === 1 ? "" : "s"} matching “${term}”.`,
|
|
260
|
+
citations: result.results.map((report) => ({ type: "report", id: report.id, reference: report.publicReference })),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function freshEnrichmentAnswer(
|
|
265
|
+
env: Env,
|
|
266
|
+
prompt: string,
|
|
267
|
+
explicitReport: string | undefined,
|
|
268
|
+
session: OperatorSession,
|
|
269
|
+
componentVersionId: string,
|
|
270
|
+
componentInput: JsonObject,
|
|
271
|
+
operationId: string,
|
|
272
|
+
): Promise<AssistantAnswer> {
|
|
273
|
+
requirePermission(session, "readReports");
|
|
274
|
+
requirePermission(session, "readEvidence");
|
|
275
|
+
requirePermission(session, "runEnrichments");
|
|
276
|
+
const reportId = await findReportId(env.DB, prompt, explicitReport);
|
|
277
|
+
if (!reportId) throw new ApiError(400, "assistant_report_reference_required", "A report reference is required for fresh enrichment.");
|
|
278
|
+
const report = await env.DB.prepare(`SELECT id, public_reference AS publicReference, queue_id AS queueId FROM reports WHERE id = ?1 LIMIT 1`)
|
|
279
|
+
.bind(reportId).first<{ id: string; publicReference: string; queueId: string | null }>();
|
|
280
|
+
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
281
|
+
assertOperatorQueueAccess(session, report.queueId);
|
|
282
|
+
const result = await executeAssistantReadOnlyComponent(env, {
|
|
283
|
+
reportId: report.id,
|
|
284
|
+
componentVersionId,
|
|
285
|
+
componentInput,
|
|
286
|
+
operationId,
|
|
287
|
+
actorId: session.actor.id,
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
toolName: "run_published_enrichment",
|
|
291
|
+
toolInput: { reportId: report.id, componentVersionId, input: componentInput },
|
|
292
|
+
toolOutput: { componentRunId: result.componentRunId, findingId: result.findingId, data: result.data },
|
|
293
|
+
body: `I ran the published read-only enrichment for ${report.publicReference} and stored finding ${result.findingId}. It did not execute any action or change the report decision.`,
|
|
294
|
+
citations: [
|
|
295
|
+
{ type: "report", id: report.id, reference: report.publicReference },
|
|
296
|
+
{ type: "component_version", id: componentVersionId },
|
|
297
|
+
{ type: "component_run", id: result.componentRunId },
|
|
298
|
+
{ type: "finding", id: result.findingId },
|
|
299
|
+
],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
type AssistantDraftType = "workflow" | "form" | "policy" | "template" | "routing_rules";
|
|
304
|
+
|
|
305
|
+
async function configurationDraftAnswer(
|
|
306
|
+
db: D1Database,
|
|
307
|
+
assistantSession: AssistantSessionRow,
|
|
308
|
+
session: OperatorSession,
|
|
309
|
+
draft: { type: AssistantDraftType; targetId?: string; title: string; content: JsonObject },
|
|
310
|
+
operationId: string,
|
|
311
|
+
): Promise<AssistantAnswer> {
|
|
312
|
+
const permission: keyof OperatorPermissions = draft.type === "workflow" ? "manageWorkflows"
|
|
313
|
+
: draft.type === "form" ? "manageForms"
|
|
314
|
+
: draft.type === "template" ? "manageTemplates"
|
|
315
|
+
: draft.type === "routing_rules" ? "manageRouting" : "managePolicies";
|
|
316
|
+
requirePermission(session, permission);
|
|
317
|
+
if (draft.title.trim().length < 3 || draft.title.length > 200) throw new ApiError(400, "assistant_draft_title_invalid", "Draft titles must contain 3 to 200 characters.");
|
|
318
|
+
if (draft.type === "workflow") {
|
|
319
|
+
const graph = parseWorkflowGraph(draft.content);
|
|
320
|
+
const validation = validateWorkflowGraph(graph);
|
|
321
|
+
if (!validation.valid) throw new ApiError(422, "assistant_workflow_draft_invalid", "The proposed workflow draft is invalid.", { errors: validation.errors });
|
|
322
|
+
if (!draft.targetId) throw new ApiError(400, "assistant_draft_target_required", "Workflow drafts require target_id.");
|
|
323
|
+
const target = await db.prepare(`SELECT id, purpose FROM workflow_definitions WHERE id = ?1 AND status != 'archived' LIMIT 1`).bind(draft.targetId).first<{ id: string; purpose: string }>();
|
|
324
|
+
if (!target || target.purpose !== graph.purpose) throw new ApiError(409, "assistant_workflow_target_invalid", "The target workflow is unavailable or has a different purpose.");
|
|
325
|
+
} else {
|
|
326
|
+
const validation = validateConfiguration(draft.type as ConfigurationType, draft.content);
|
|
327
|
+
if (!validation.valid) throw new ApiError(422, "assistant_configuration_draft_invalid", "The proposed configuration draft is invalid.", { errors: validation.errors });
|
|
328
|
+
}
|
|
329
|
+
const existing = await db.prepare(`SELECT id FROM assistant_drafts WHERE session_id = ?1 AND idempotency_key = ?2 LIMIT 1`)
|
|
330
|
+
.bind(assistantSession.id, operationId).first<{ id: string }>();
|
|
331
|
+
const draftId = existing?.id ?? crypto.randomUUID();
|
|
332
|
+
const contentJson = canonicalJson(draft.content);
|
|
333
|
+
if (!existing) await db.prepare(`
|
|
334
|
+
INSERT INTO assistant_drafts (
|
|
335
|
+
id, installation_id, session_id, principal_id, draft_type,
|
|
336
|
+
target_id, title, content_json, content_digest, status,
|
|
337
|
+
idempotency_key, created_at
|
|
338
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'draft', ?9, ?10)
|
|
339
|
+
`).bind(
|
|
340
|
+
draftId, assistantSession.id, session.actor.id, draft.type, draft.targetId ?? null,
|
|
341
|
+
draft.title.trim(), contentJson, `sha256:${await sha256(contentJson)}`, operationId, now(),
|
|
342
|
+
).run();
|
|
343
|
+
return {
|
|
344
|
+
toolName: "create_configuration_draft",
|
|
345
|
+
toolInput: { draftType: draft.type, targetId: draft.targetId ?? null, title: draft.title },
|
|
346
|
+
toolOutput: { draftId, status: "draft", publishable: false },
|
|
347
|
+
body: `I created unpublished ${draft.type.replace(/_/gu, " ")} draft ${draftId}. Adopting it only copies it into the normal editor; it cannot publish itself.`,
|
|
348
|
+
citations: [{ type: "assistant_draft", id: draftId, draft_type: draft.type, target_id: draft.targetId ?? null }],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function answerPrompt(
|
|
353
|
+
env: Env,
|
|
354
|
+
prompt: string,
|
|
355
|
+
assistantSession: AssistantSessionRow,
|
|
356
|
+
session: OperatorSession,
|
|
357
|
+
explicitReport?: string,
|
|
358
|
+
componentVersionId?: string,
|
|
359
|
+
componentInput?: JsonObject,
|
|
360
|
+
operationId?: string,
|
|
361
|
+
draft?: { type: AssistantDraftType; targetId?: string; title: string; content: JsonObject },
|
|
362
|
+
): Promise<AssistantAnswer> {
|
|
363
|
+
const db = env.DB;
|
|
364
|
+
if (draft && operationId) return configurationDraftAnswer(db, assistantSession, session, draft, operationId);
|
|
365
|
+
if (componentVersionId && componentInput && operationId) {
|
|
366
|
+
return freshEnrichmentAnswer(env, prompt, explicitReport, session, componentVersionId, componentInput, operationId);
|
|
367
|
+
}
|
|
368
|
+
const lowered = prompt.toLocaleLowerCase("en-US");
|
|
369
|
+
if (/\bhow many\b|\bcount\b|\bmetrics?\b/u.test(lowered)) return metricsAnswer(db, prompt, assistantSession.timezone, session);
|
|
370
|
+
if (/\bwhy\b|\bexplain\b|\brationale\b|\bdecision trace\b/u.test(lowered)) return explainAnswer(db, prompt, explicitReport, session);
|
|
371
|
+
if (/\bfind\b|\bsearch\b|\bshow\b/u.test(lowered)) return searchAnswer(db, prompt, session);
|
|
372
|
+
return {
|
|
373
|
+
toolName: "capability_help",
|
|
374
|
+
toolInput: {},
|
|
375
|
+
toolOutput: { supported: ["query_metrics", "search_reports", "get_decision_trace", "run_published_enrichment"] },
|
|
376
|
+
body: "I can count reports over an exact time window, search accessible reports, explain a report from stored records, or run an explicitly selected published read-only enrichment. Include a report reference for report-specific tools.",
|
|
377
|
+
citations: [],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function sendAssistantMessage(
|
|
382
|
+
env: Env,
|
|
383
|
+
sessionId: string,
|
|
384
|
+
input: {
|
|
385
|
+
body: string;
|
|
386
|
+
reportReference?: string;
|
|
387
|
+
componentVersionId?: string;
|
|
388
|
+
componentInput?: JsonObject;
|
|
389
|
+
draft?: { type: AssistantDraftType; targetId?: string; title: string; content: JsonObject };
|
|
390
|
+
},
|
|
391
|
+
session: OperatorSession,
|
|
392
|
+
idempotencyKey: string,
|
|
393
|
+
): Promise<{ userMessageId: string; assistantMessageId: string; toolCallId: string; body: string; citations: Record<string, unknown>[]; idempotentReplay: boolean }> {
|
|
394
|
+
const db = env.DB;
|
|
395
|
+
const existing = await db.prepare(`
|
|
396
|
+
SELECT u.id AS userMessageId, a.id AS assistantMessageId, a.body,
|
|
397
|
+
a.citations_json AS citationsJson, t.id AS toolCallId
|
|
398
|
+
FROM assistant_messages u JOIN assistant_messages a ON a.session_id = u.session_id
|
|
399
|
+
AND a.idempotency_key = u.idempotency_key || ':answer'
|
|
400
|
+
JOIN assistant_tool_calls t ON t.message_id = a.id
|
|
401
|
+
WHERE u.session_id = ?1 AND u.idempotency_key = ?2 LIMIT 1
|
|
402
|
+
`).bind(sessionId, idempotencyKey).first<{
|
|
403
|
+
userMessageId: string; assistantMessageId: string; body: string; citationsJson: string; toolCallId: string;
|
|
404
|
+
}>();
|
|
405
|
+
if (existing) return {
|
|
406
|
+
userMessageId: existing.userMessageId,
|
|
407
|
+
assistantMessageId: existing.assistantMessageId,
|
|
408
|
+
toolCallId: existing.toolCallId,
|
|
409
|
+
body: existing.body,
|
|
410
|
+
citations: JSON.parse(existing.citationsJson) as Record<string, unknown>[],
|
|
411
|
+
idempotentReplay: true,
|
|
412
|
+
};
|
|
413
|
+
const assistantSession = await loadAssistantSession(db, sessionId, session.actor.id);
|
|
414
|
+
const prompt = input.body.trim();
|
|
415
|
+
if (prompt.length < 2 || prompt.length > 8_000) throw new ApiError(400, "assistant_message_invalid", "Assistant messages must contain 2 to 8,000 characters.");
|
|
416
|
+
const userMessageId = crypto.randomUUID();
|
|
417
|
+
const assistantMessageId = crypto.randomUUID();
|
|
418
|
+
const toolCallId = crypto.randomUUID();
|
|
419
|
+
const startedAt = now();
|
|
420
|
+
let budgetReservations;
|
|
421
|
+
try {
|
|
422
|
+
budgetReservations = await reserveBudgets(db, {
|
|
423
|
+
installationId: "default",
|
|
424
|
+
scopes: [{ type: "assistant", id: sessionId }, { type: "assistant", id: session.actor.id }],
|
|
425
|
+
metric: "calls", amount: 1, operationType: "assistant_message",
|
|
426
|
+
operationId: `${sessionId}:${idempotencyKey}`, attemptId: "1",
|
|
427
|
+
});
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error instanceof BudgetControlError) {
|
|
430
|
+
throw new ApiError(error.retryable ? 429 : 409, error.code, "The assistant budget is unavailable for this request.", {
|
|
431
|
+
retryable: error.retryable,
|
|
432
|
+
retry_after_seconds: error.delaySeconds,
|
|
433
|
+
fallback_reference: error.fallbackReference,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
let answer: AssistantAnswer;
|
|
439
|
+
try {
|
|
440
|
+
answer = await answerPrompt(
|
|
441
|
+
env,
|
|
442
|
+
prompt,
|
|
443
|
+
assistantSession,
|
|
444
|
+
session,
|
|
445
|
+
input.reportReference,
|
|
446
|
+
input.componentVersionId,
|
|
447
|
+
input.componentInput,
|
|
448
|
+
`${sessionId}:${idempotencyKey}`,
|
|
449
|
+
input.draft,
|
|
450
|
+
);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
await db.prepare(`
|
|
453
|
+
INSERT INTO assistant_tool_calls (
|
|
454
|
+
id, session_id, tool_name, permission_snapshot_json, input_digest,
|
|
455
|
+
status, error_code, started_at, finished_at
|
|
456
|
+
) VALUES (?1, ?2, 'request_router', ?3, ?4, 'denied', ?5, ?6, ?6)
|
|
457
|
+
`).bind(
|
|
458
|
+
toolCallId, sessionId, canonicalJson(permissionSnapshot(session)),
|
|
459
|
+
`sha256:${await sha256(canonicalJson({ prompt }))}`,
|
|
460
|
+
error instanceof ApiError ? error.code : "assistant_tool_failed", now(),
|
|
461
|
+
).run();
|
|
462
|
+
await settleBudgets(db, budgetReservations, true);
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
const inputJson = canonicalJson(answer.toolInput);
|
|
466
|
+
const outputJson = canonicalJson(answer.toolOutput);
|
|
467
|
+
const finishedAt = now();
|
|
468
|
+
const auditId = crypto.randomUUID();
|
|
469
|
+
try {
|
|
470
|
+
await db.batch([
|
|
471
|
+
db.prepare(`
|
|
472
|
+
INSERT INTO assistant_messages (id, session_id, role, body, citations_json, created_at, idempotency_key)
|
|
473
|
+
VALUES (?1, ?2, 'user', ?3, '[]', ?4, ?5)
|
|
474
|
+
`).bind(userMessageId, sessionId, prompt, startedAt, idempotencyKey),
|
|
475
|
+
db.prepare(`
|
|
476
|
+
INSERT INTO assistant_messages (
|
|
477
|
+
id, session_id, role, body, citations_json, created_at, idempotency_key
|
|
478
|
+
) VALUES (?1, ?2, 'assistant', ?3, ?4, ?5, ?6)
|
|
479
|
+
`).bind(assistantMessageId, sessionId, answer.body, canonicalJson(answer.citations), finishedAt, `${idempotencyKey}:answer`),
|
|
480
|
+
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, 'assistant.tool_called', ?3, ?4,
|
|
485
|
+
'assistant_session', ?5, ?6, ?7)
|
|
486
|
+
`).bind(
|
|
487
|
+
auditId, `assistant-tool:${sessionId}:${idempotencyKey}`,
|
|
488
|
+
session.actor.type, session.actor.id, sessionId,
|
|
489
|
+
canonicalJson({ toolName: answer.toolName, inputDigest: `sha256:${await sha256(inputJson)}`, outputDigest: `sha256:${await sha256(outputJson)}` }),
|
|
490
|
+
finishedAt,
|
|
491
|
+
),
|
|
492
|
+
db.prepare(`
|
|
493
|
+
INSERT INTO assistant_tool_calls (
|
|
494
|
+
id, session_id, message_id, tool_name, permission_snapshot_json,
|
|
495
|
+
input_json, input_digest, output_summary_json, output_digest,
|
|
496
|
+
status, started_at, finished_at, audit_event_id
|
|
497
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'succeeded', ?10, ?11, ?12)
|
|
498
|
+
`).bind(
|
|
499
|
+
toolCallId, sessionId, assistantMessageId, answer.toolName,
|
|
500
|
+
canonicalJson(permissionSnapshot(session)), inputJson, `sha256:${await sha256(inputJson)}`,
|
|
501
|
+
outputJson, `sha256:${await sha256(outputJson)}`, startedAt, finishedAt, auditId,
|
|
502
|
+
),
|
|
503
|
+
db.prepare(`UPDATE assistant_sessions SET updated_at = ?2 WHERE id = ?1 AND status = 'active'`).bind(sessionId, finishedAt),
|
|
504
|
+
]);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
await settleBudgets(db, budgetReservations, false);
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
await settleBudgets(db, budgetReservations, true);
|
|
510
|
+
return { userMessageId, assistantMessageId, toolCallId, body: answer.body, citations: answer.citations, idempotentReplay: false };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export async function closeAssistantSession(db: D1Database, sessionId: string, session: OperatorSession): Promise<void> {
|
|
514
|
+
const result = await db.prepare(`UPDATE assistant_sessions SET status = 'closed', updated_at = ?3 WHERE id = ?1 AND principal_id = ?2 AND status = 'active'`)
|
|
515
|
+
.bind(sessionId, session.actor.id, now()).run();
|
|
516
|
+
if (result.meta.changes !== 1) throw new ApiError(404, "assistant_session_not_found", "The active assistant session does not exist.");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function loadAssistantToolCall(db: D1Database, toolCallId: string, session: OperatorSession): Promise<Record<string, unknown>> {
|
|
520
|
+
const row = await db.prepare(`
|
|
521
|
+
SELECT t.*, s.principal_id AS principalId FROM assistant_tool_calls t
|
|
522
|
+
JOIN assistant_sessions s ON s.id = t.session_id
|
|
523
|
+
WHERE t.id = ?1 AND s.principal_id = ?2 LIMIT 1
|
|
524
|
+
`).bind(toolCallId, session.actor.id).first<Record<string, unknown>>();
|
|
525
|
+
if (!row) throw new ApiError(404, "assistant_tool_call_not_found", "The assistant tool call does not exist.");
|
|
526
|
+
return row;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function loadAssistantDraft(db: D1Database, draftId: string, session: OperatorSession): Promise<Record<string, unknown>> {
|
|
530
|
+
const row = await db.prepare(`
|
|
531
|
+
SELECT id, draft_type AS draftType, target_id AS targetId, title,
|
|
532
|
+
content_json AS contentJson, content_digest AS contentDigest,
|
|
533
|
+
status, created_at AS createdAt, adopted_at AS adoptedAt,
|
|
534
|
+
adopted_target_id AS adoptedTargetId
|
|
535
|
+
FROM assistant_drafts WHERE id = ?1 AND principal_id = ?2 LIMIT 1
|
|
536
|
+
`).bind(draftId, session.actor.id).first<Record<string, unknown>>();
|
|
537
|
+
if (!row) throw new ApiError(404, "assistant_draft_not_found", "The assistant draft does not exist.");
|
|
538
|
+
return { ...row, content: JSON.parse(String(row.contentJson)), contentJson: undefined };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function markAssistantDraftAdopted(
|
|
542
|
+
db: D1Database,
|
|
543
|
+
draftId: string,
|
|
544
|
+
adoptedTargetId: string,
|
|
545
|
+
session: OperatorSession,
|
|
546
|
+
idempotencyKey: string,
|
|
547
|
+
): Promise<void> {
|
|
548
|
+
const timestamp = now();
|
|
549
|
+
const changed = await db.prepare(`UPDATE assistant_drafts SET status = 'adopted', adopted_at = ?3, adopted_target_id = ?4 WHERE id = ?1 AND principal_id = ?2 AND status = 'draft'`)
|
|
550
|
+
.bind(draftId, session.actor.id, timestamp, adoptedTargetId).run();
|
|
551
|
+
if (changed.meta.changes !== 1) throw new ApiError(409, "assistant_draft_not_adoptable", "The assistant draft is no longer adoptable.");
|
|
552
|
+
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, 'assistant.draft_adopted', ?3, ?4, 'assistant_draft', ?5, ?6, ?7)`)
|
|
553
|
+
.bind(crypto.randomUUID(), `assistant-draft-adopt:${idempotencyKey}`, session.actor.type, session.actor.id, draftId, canonicalJson({ adoptedTargetId }), timestamp).run();
|
|
554
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const AUDIT_GENESIS_HASH = "0".repeat(64);
|
|
2
|
+
|
|
3
|
+
export interface AuditHashInput {
|
|
4
|
+
sequence: number;
|
|
5
|
+
action: string;
|
|
6
|
+
actorType: string;
|
|
7
|
+
actorId: string;
|
|
8
|
+
targetType: string;
|
|
9
|
+
targetId: string;
|
|
10
|
+
detailsJson: string;
|
|
11
|
+
previousHash: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function canonicalJson(value: unknown): string {
|
|
16
|
+
if (value === undefined) return "null";
|
|
17
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
19
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
20
|
+
.filter(([, child]) => child !== undefined)
|
|
21
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
22
|
+
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function hashAuditEntry(input: AuditHashInput): Promise<string> {
|
|
26
|
+
const payload = canonicalJson({
|
|
27
|
+
sequence: input.sequence,
|
|
28
|
+
action: input.action,
|
|
29
|
+
actorType: input.actorType,
|
|
30
|
+
actorId: input.actorId,
|
|
31
|
+
targetType: input.targetType,
|
|
32
|
+
targetId: input.targetId,
|
|
33
|
+
detailsJson: input.detailsJson,
|
|
34
|
+
previousHash: input.previousHash,
|
|
35
|
+
createdAt: input.createdAt,
|
|
36
|
+
});
|
|
37
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(payload));
|
|
38
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
}
|