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,916 @@
|
|
|
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
|
+
import { compileWorkflowGraph } from "./workflow-compiler";
|
|
6
|
+
import type {
|
|
7
|
+
WorkflowAuthorityMode,
|
|
8
|
+
WorkflowDefinitionInput,
|
|
9
|
+
WorkflowDependencyReference,
|
|
10
|
+
WorkflowGraphV1,
|
|
11
|
+
WorkflowPublishInput,
|
|
12
|
+
WorkflowValidationResult,
|
|
13
|
+
} from "./workflow-platform-types";
|
|
14
|
+
import {
|
|
15
|
+
canonicalWorkflowGraph,
|
|
16
|
+
parseWorkflowGraph,
|
|
17
|
+
validateWorkflowGraph,
|
|
18
|
+
} from "./workflow-platform-validation";
|
|
19
|
+
import { simulateWorkflow, simulationMatchesExpected } from "./workflow-simulator";
|
|
20
|
+
import type { JsonObject } from "./workflow-platform-types";
|
|
21
|
+
|
|
22
|
+
interface WorkflowListRow {
|
|
23
|
+
id: string;
|
|
24
|
+
key: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
purpose: string;
|
|
28
|
+
status: string;
|
|
29
|
+
activeVersionId: string | null;
|
|
30
|
+
createdBy: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
draftId: string | null;
|
|
34
|
+
draftRevision: number | null;
|
|
35
|
+
validationStatus: string | null;
|
|
36
|
+
validationJson: string | null;
|
|
37
|
+
version: number | null;
|
|
38
|
+
authorityMode: string | null;
|
|
39
|
+
publishedAt: string | null;
|
|
40
|
+
activePinCount: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface DraftRow {
|
|
44
|
+
id: string;
|
|
45
|
+
workflow_id: string;
|
|
46
|
+
graph_json: string;
|
|
47
|
+
graph_digest: string;
|
|
48
|
+
revision: number;
|
|
49
|
+
validation_json: string;
|
|
50
|
+
validation_status: "unvalidated" | "valid" | "invalid";
|
|
51
|
+
updated_at: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MutationRow {
|
|
55
|
+
action: string;
|
|
56
|
+
actor_id: string;
|
|
57
|
+
response_json: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function now(): string {
|
|
61
|
+
return new Date().toISOString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parsedJson<T>(value: string | null, fallback: T): T {
|
|
65
|
+
if (!value) return fallback;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(value) as T;
|
|
68
|
+
} catch {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function workflowFromRow(row: WorkflowListRow): Record<string, unknown> {
|
|
74
|
+
return {
|
|
75
|
+
id: row.id,
|
|
76
|
+
key: row.key,
|
|
77
|
+
name: row.name,
|
|
78
|
+
description: row.description,
|
|
79
|
+
purpose: row.purpose,
|
|
80
|
+
status: row.status,
|
|
81
|
+
activeVersionId: row.activeVersionId,
|
|
82
|
+
createdBy: row.createdBy,
|
|
83
|
+
createdAt: row.createdAt,
|
|
84
|
+
updatedAt: row.updatedAt,
|
|
85
|
+
draft: row.draftId ? {
|
|
86
|
+
id: row.draftId,
|
|
87
|
+
revision: row.draftRevision,
|
|
88
|
+
validationStatus: row.validationStatus,
|
|
89
|
+
validation: parsedJson(row.validationJson, { valid: false, errors: [], warnings: [] }),
|
|
90
|
+
} : null,
|
|
91
|
+
activeVersion: row.activeVersionId ? {
|
|
92
|
+
id: row.activeVersionId,
|
|
93
|
+
version: row.version,
|
|
94
|
+
authorityMode: row.authorityMode,
|
|
95
|
+
publishedAt: row.publishedAt,
|
|
96
|
+
} : null,
|
|
97
|
+
activePinCount: Number(row.activePinCount),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function mutationReplay(
|
|
102
|
+
db: D1Database,
|
|
103
|
+
idempotencyKey: string,
|
|
104
|
+
action: string,
|
|
105
|
+
actorId: string,
|
|
106
|
+
): Promise<Record<string, unknown> | null> {
|
|
107
|
+
const row = await db.prepare(`
|
|
108
|
+
SELECT action, actor_id, response_json FROM configuration_mutations WHERE idempotency_key = ?1 LIMIT 1
|
|
109
|
+
`).bind(idempotencyKey).first<MutationRow>();
|
|
110
|
+
if (!row) return null;
|
|
111
|
+
if (row.action !== action || row.actor_id !== actorId) {
|
|
112
|
+
throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another workflow mutation.");
|
|
113
|
+
}
|
|
114
|
+
return parsedJson<Record<string, unknown>>(row.response_json, {});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function auditStatement(
|
|
118
|
+
db: D1Database,
|
|
119
|
+
input: {
|
|
120
|
+
idempotencyKey: string;
|
|
121
|
+
action: string;
|
|
122
|
+
session: OperatorSession;
|
|
123
|
+
targetType: string;
|
|
124
|
+
targetId: string;
|
|
125
|
+
details: unknown;
|
|
126
|
+
createdAt: string;
|
|
127
|
+
},
|
|
128
|
+
): D1PreparedStatement {
|
|
129
|
+
return db.prepare(`
|
|
130
|
+
INSERT OR IGNORE INTO audit_events (
|
|
131
|
+
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
132
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
133
|
+
`).bind(
|
|
134
|
+
crypto.randomUUID(), input.idempotencyKey, input.action, input.session.actor.type,
|
|
135
|
+
input.session.actor.id, input.targetType, input.targetId, canonicalJson(input.details), input.createdAt,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function listWorkflowDefinitions(
|
|
140
|
+
db: D1Database,
|
|
141
|
+
includeArchived = false,
|
|
142
|
+
): Promise<Record<string, unknown>[]> {
|
|
143
|
+
const rows = await db.prepare(`
|
|
144
|
+
SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
|
|
145
|
+
w.active_version_id AS activeVersionId, w.created_by AS createdBy,
|
|
146
|
+
w.created_at AS createdAt, w.updated_at AS updatedAt,
|
|
147
|
+
d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
|
|
148
|
+
d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
|
|
149
|
+
v.published_at AS publishedAt,
|
|
150
|
+
(SELECT COUNT(*) FROM queue_workflow_pins p
|
|
151
|
+
WHERE p.workflow_version_id = w.active_version_id AND p.deactivated_at IS NULL) AS activePinCount
|
|
152
|
+
FROM workflow_definitions w
|
|
153
|
+
LEFT JOIN workflow_drafts d ON d.workflow_id = w.id
|
|
154
|
+
LEFT JOIN workflow_versions v ON v.id = w.active_version_id
|
|
155
|
+
WHERE (?1 = 1 OR w.status != 'archived')
|
|
156
|
+
ORDER BY w.name, w.id
|
|
157
|
+
`).bind(includeArchived ? 1 : 0).all<WorkflowListRow>();
|
|
158
|
+
return rows.results.map(workflowFromRow);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function loadWorkflowDefinition(
|
|
162
|
+
db: D1Database,
|
|
163
|
+
workflowId: string,
|
|
164
|
+
): Promise<Record<string, unknown> | null> {
|
|
165
|
+
const row = await db.prepare(`
|
|
166
|
+
SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
|
|
167
|
+
w.active_version_id AS activeVersionId, w.created_by AS createdBy,
|
|
168
|
+
w.created_at AS createdAt, w.updated_at AS updatedAt,
|
|
169
|
+
d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
|
|
170
|
+
d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
|
|
171
|
+
v.published_at AS publishedAt,
|
|
172
|
+
(SELECT COUNT(*) FROM queue_workflow_pins p
|
|
173
|
+
WHERE p.workflow_version_id = w.active_version_id AND p.deactivated_at IS NULL) AS activePinCount
|
|
174
|
+
FROM workflow_definitions w
|
|
175
|
+
LEFT JOIN workflow_drafts d ON d.workflow_id = w.id
|
|
176
|
+
LEFT JOIN workflow_versions v ON v.id = w.active_version_id
|
|
177
|
+
WHERE w.id = ?1 LIMIT 1
|
|
178
|
+
`).bind(workflowId).first<WorkflowListRow>();
|
|
179
|
+
if (!row) return null;
|
|
180
|
+
const [draft, versions, fixtures] = await Promise.all([
|
|
181
|
+
db.prepare(`
|
|
182
|
+
SELECT id, workflow_id, graph_json, graph_digest, revision, validation_json, validation_status, updated_at
|
|
183
|
+
FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1
|
|
184
|
+
`).bind(workflowId).first<DraftRow>(),
|
|
185
|
+
db.prepare(`
|
|
186
|
+
SELECT v.id, v.version, v.graph_digest AS graphDigest, v.authority_mode AS authorityMode,
|
|
187
|
+
v.dependency_manifest_json AS dependencyManifestJson,
|
|
188
|
+
v.capability_manifest_json AS capabilityManifestJson,
|
|
189
|
+
v.artifact_id AS artifactId, v.compiler_version AS compilerVersion,
|
|
190
|
+
v.test_evidence_json AS testEvidenceJson, v.published_by AS publishedBy,
|
|
191
|
+
v.published_at AS publishedAt, v.retired_at AS retiredAt,
|
|
192
|
+
a.bundle_digest AS bundleDigest, a.status AS artifactStatus
|
|
193
|
+
FROM workflow_versions v JOIN workflow_artifacts a ON a.id = v.artifact_id
|
|
194
|
+
WHERE v.workflow_id = ?1 ORDER BY v.version DESC, v.id DESC
|
|
195
|
+
`).bind(workflowId).all<Record<string, unknown>>(),
|
|
196
|
+
db.prepare(`
|
|
197
|
+
SELECT id, name, input_json AS inputJson, expected_json AS expectedJson,
|
|
198
|
+
failure_injection_json AS failureInjectionJson, updated_at AS updatedAt
|
|
199
|
+
FROM workflow_fixtures WHERE workflow_id = ?1 ORDER BY name, id
|
|
200
|
+
`).bind(workflowId).all<Record<string, unknown>>(),
|
|
201
|
+
]);
|
|
202
|
+
return {
|
|
203
|
+
...workflowFromRow(row),
|
|
204
|
+
draft: draft ? {
|
|
205
|
+
id: draft.id,
|
|
206
|
+
revision: draft.revision,
|
|
207
|
+
graphDigest: draft.graph_digest,
|
|
208
|
+
graph: parsedJson(draft.graph_json, null),
|
|
209
|
+
validationStatus: draft.validation_status,
|
|
210
|
+
validation: parsedJson(draft.validation_json, { valid: false, errors: [], warnings: [] }),
|
|
211
|
+
updatedAt: draft.updated_at,
|
|
212
|
+
} : null,
|
|
213
|
+
versions: versions.results.map((version) => ({
|
|
214
|
+
...version,
|
|
215
|
+
dependencyManifest: parsedJson(String(version.dependencyManifestJson ?? ""), []),
|
|
216
|
+
capabilityManifest: parsedJson(String(version.capabilityManifestJson ?? ""), {}),
|
|
217
|
+
testEvidence: parsedJson(String(version.testEvidenceJson ?? ""), {}),
|
|
218
|
+
dependencyManifestJson: undefined,
|
|
219
|
+
capabilityManifestJson: undefined,
|
|
220
|
+
testEvidenceJson: undefined,
|
|
221
|
+
})),
|
|
222
|
+
fixtures: fixtures.results.map((fixture) => ({
|
|
223
|
+
...fixture,
|
|
224
|
+
input: parsedJson(String(fixture.inputJson ?? ""), {}),
|
|
225
|
+
expected: parsedJson(String(fixture.expectedJson ?? ""), {}),
|
|
226
|
+
failureInjection: parsedJson(String(fixture.failureInjectionJson ?? ""), {}),
|
|
227
|
+
inputJson: undefined,
|
|
228
|
+
expectedJson: undefined,
|
|
229
|
+
failureInjectionJson: undefined,
|
|
230
|
+
})),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function createWorkflowDefinition(
|
|
235
|
+
db: D1Database,
|
|
236
|
+
input: WorkflowDefinitionInput,
|
|
237
|
+
graph: WorkflowGraphV1,
|
|
238
|
+
session: OperatorSession,
|
|
239
|
+
idempotencyKey: string,
|
|
240
|
+
): Promise<{ workflowId: string; draftId: string; revision: number; idempotentReplay: boolean }> {
|
|
241
|
+
const replay = await mutationReplay(db, idempotencyKey, "workflow.create", session.actor.id);
|
|
242
|
+
if (replay) return {
|
|
243
|
+
workflowId: String(replay.workflowId), draftId: String(replay.draftId), revision: Number(replay.revision), idempotentReplay: true,
|
|
244
|
+
};
|
|
245
|
+
if (graph.purpose !== input.purpose) throw new ApiError(400, "workflow_purpose_mismatch", "The workflow definition and graph purposes must match.");
|
|
246
|
+
const conflict = await db.prepare(`
|
|
247
|
+
SELECT id FROM workflow_definitions WHERE installation_id = 'default' AND workflow_key = ?1 LIMIT 1
|
|
248
|
+
`).bind(input.key).first<{ id: string }>();
|
|
249
|
+
if (conflict) throw new ApiError(409, "workflow_key_exists", "A workflow with this key already exists.");
|
|
250
|
+
const workflowId = crypto.randomUUID();
|
|
251
|
+
const draftId = crypto.randomUUID();
|
|
252
|
+
const createdAt = now();
|
|
253
|
+
const graphJson = canonicalWorkflowGraph(graph);
|
|
254
|
+
const graphDigest = `sha256:${await sha256(graphJson)}`;
|
|
255
|
+
const validation = validateWorkflowGraph(graph);
|
|
256
|
+
const response = { workflowId, draftId, revision: 1 };
|
|
257
|
+
await db.batch([
|
|
258
|
+
db.prepare(`
|
|
259
|
+
INSERT INTO workflow_definitions (
|
|
260
|
+
id, installation_id, workflow_key, name, description, purpose, status,
|
|
261
|
+
created_by, created_at, updated_at
|
|
262
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7)
|
|
263
|
+
`).bind(workflowId, input.key, input.name, input.description, input.purpose, session.actor.id, createdAt),
|
|
264
|
+
db.prepare(`
|
|
265
|
+
INSERT INTO workflow_drafts (
|
|
266
|
+
id, workflow_id, graph_json, graph_digest, revision, validation_json,
|
|
267
|
+
validation_digest, validation_status, created_by, updated_by, created_at, updated_at
|
|
268
|
+
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?8, ?9, ?9)
|
|
269
|
+
`).bind(
|
|
270
|
+
draftId, workflowId, graphJson, graphDigest, canonicalJson(validation),
|
|
271
|
+
`sha256:${await sha256(canonicalJson(validation))}`, validation.valid ? "valid" : "invalid",
|
|
272
|
+
session.actor.id, createdAt,
|
|
273
|
+
),
|
|
274
|
+
db.prepare(`
|
|
275
|
+
INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
|
|
276
|
+
VALUES (?1, 'workflow.create', ?2, ?3, ?4, ?5)
|
|
277
|
+
`).bind(idempotencyKey, session.actor.id, workflowId, canonicalJson(response), createdAt),
|
|
278
|
+
auditStatement(db, {
|
|
279
|
+
idempotencyKey: `workflow-create:${idempotencyKey}`, action: "workflow.created", session,
|
|
280
|
+
targetType: "workflow", targetId: workflowId,
|
|
281
|
+
details: { key: input.key, purpose: input.purpose, draftId, graphDigest }, createdAt,
|
|
282
|
+
}),
|
|
283
|
+
]);
|
|
284
|
+
return { ...response, idempotentReplay: false };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function replaceWorkflowDraft(
|
|
288
|
+
db: D1Database,
|
|
289
|
+
workflowId: string,
|
|
290
|
+
graph: WorkflowGraphV1,
|
|
291
|
+
expectedRevision: number,
|
|
292
|
+
session: OperatorSession,
|
|
293
|
+
): Promise<{ draftId: string; revision: number; graphDigest: string; validation: WorkflowValidationResult }> {
|
|
294
|
+
const draft = await db.prepare(`
|
|
295
|
+
SELECT d.id, d.workflow_id, d.graph_json, d.graph_digest, d.revision,
|
|
296
|
+
d.validation_json, d.validation_status, d.updated_at
|
|
297
|
+
FROM workflow_drafts d JOIN workflow_definitions w ON w.id = d.workflow_id
|
|
298
|
+
WHERE d.workflow_id = ?1 AND w.status != 'archived' LIMIT 1
|
|
299
|
+
`).bind(workflowId).first<DraftRow>();
|
|
300
|
+
if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow does not exist or is archived.");
|
|
301
|
+
if (draft.revision !== expectedRevision) throw new ApiError(409, "workflow_revision_conflict", "The workflow draft changed after it was loaded.", {
|
|
302
|
+
expected_revision: expectedRevision, current_revision: draft.revision,
|
|
303
|
+
});
|
|
304
|
+
const purpose = await db.prepare(`SELECT purpose FROM workflow_definitions WHERE id = ?1`).bind(workflowId).first<{ purpose: string }>();
|
|
305
|
+
if (!purpose || graph.purpose !== purpose.purpose) throw new ApiError(400, "workflow_purpose_mismatch", "The workflow definition and graph purposes must match.");
|
|
306
|
+
const graphJson = canonicalWorkflowGraph(graph);
|
|
307
|
+
const graphDigest = `sha256:${await sha256(graphJson)}`;
|
|
308
|
+
const validation = validateWorkflowGraph(graph);
|
|
309
|
+
const validationJson = canonicalJson(validation);
|
|
310
|
+
const updatedAt = now();
|
|
311
|
+
const revision = draft.revision + 1;
|
|
312
|
+
const result = await db.prepare(`
|
|
313
|
+
UPDATE workflow_drafts
|
|
314
|
+
SET graph_json = ?2, graph_digest = ?3, revision = ?4,
|
|
315
|
+
validation_json = ?5, validation_digest = ?6, validation_status = ?7,
|
|
316
|
+
updated_by = ?8, updated_at = ?9
|
|
317
|
+
WHERE id = ?1 AND revision = ?10
|
|
318
|
+
`).bind(
|
|
319
|
+
draft.id, graphJson, graphDigest, revision, validationJson,
|
|
320
|
+
`sha256:${await sha256(validationJson)}`, validation.valid ? "valid" : "invalid",
|
|
321
|
+
session.actor.id, updatedAt, expectedRevision,
|
|
322
|
+
).run();
|
|
323
|
+
if (result.meta.changes !== 1) throw new ApiError(409, "workflow_revision_conflict", "The workflow draft changed while it was being saved.");
|
|
324
|
+
return { draftId: draft.id, revision, graphDigest, validation };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function validateWorkflowDraft(
|
|
328
|
+
db: D1Database,
|
|
329
|
+
workflowId: string,
|
|
330
|
+
authorityMode: WorkflowAuthorityMode,
|
|
331
|
+
): Promise<{ revision: number; graphDigest: string; validation: WorkflowValidationResult }> {
|
|
332
|
+
const draft = await db.prepare(`
|
|
333
|
+
SELECT id, workflow_id, graph_json, graph_digest, revision,
|
|
334
|
+
validation_json, validation_status, updated_at
|
|
335
|
+
FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1
|
|
336
|
+
`).bind(workflowId).first<DraftRow>();
|
|
337
|
+
if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
|
|
338
|
+
const graph = parseWorkflowGraph(parsedJson(draft.graph_json, null));
|
|
339
|
+
const validation = validateWorkflowGraph(graph, authorityMode);
|
|
340
|
+
const validationJson = canonicalJson(validation);
|
|
341
|
+
await db.prepare(`
|
|
342
|
+
UPDATE workflow_drafts SET validation_json = ?2, validation_digest = ?3,
|
|
343
|
+
validation_status = ?4, updated_at = ?5 WHERE id = ?1 AND revision = ?6
|
|
344
|
+
`).bind(
|
|
345
|
+
draft.id, validationJson, `sha256:${await sha256(validationJson)}`,
|
|
346
|
+
validation.valid ? "valid" : "invalid", now(), draft.revision,
|
|
347
|
+
).run();
|
|
348
|
+
return { revision: draft.revision, graphDigest: draft.graph_digest, validation };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function listWorkflowFixtures(db: D1Database, workflowId: string): Promise<Record<string, unknown>[]> {
|
|
352
|
+
const rows = await db.prepare(`
|
|
353
|
+
SELECT id, name, input_json AS inputJson, component_results_json AS componentResultsJson,
|
|
354
|
+
human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
|
|
355
|
+
failure_injection_json AS failureInjectionJson, created_by AS createdBy,
|
|
356
|
+
created_at AS createdAt, updated_at AS updatedAt
|
|
357
|
+
FROM workflow_fixtures WHERE workflow_id = ?1 ORDER BY name, id
|
|
358
|
+
`).bind(workflowId).all<Record<string, unknown>>();
|
|
359
|
+
return rows.results.map((row) => ({
|
|
360
|
+
...row,
|
|
361
|
+
input: parsedJson(String(row.inputJson ?? ""), {}),
|
|
362
|
+
componentResults: parsedJson(String(row.componentResultsJson ?? ""), {}),
|
|
363
|
+
humanResponses: parsedJson(String(row.humanResponsesJson ?? ""), {}),
|
|
364
|
+
expected: parsedJson(String(row.expectedJson ?? ""), {}),
|
|
365
|
+
failureInjection: parsedJson(String(row.failureInjectionJson ?? ""), {}),
|
|
366
|
+
inputJson: undefined,
|
|
367
|
+
componentResultsJson: undefined,
|
|
368
|
+
humanResponsesJson: undefined,
|
|
369
|
+
expectedJson: undefined,
|
|
370
|
+
failureInjectionJson: undefined,
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function createWorkflowFixture(
|
|
375
|
+
db: D1Database,
|
|
376
|
+
workflowId: string,
|
|
377
|
+
input: {
|
|
378
|
+
name: string;
|
|
379
|
+
input: JsonObject;
|
|
380
|
+
componentResults: JsonObject;
|
|
381
|
+
humanResponses: JsonObject;
|
|
382
|
+
expected: JsonObject;
|
|
383
|
+
failureInjection: JsonObject;
|
|
384
|
+
},
|
|
385
|
+
session: OperatorSession,
|
|
386
|
+
idempotencyKey: string,
|
|
387
|
+
): Promise<{ fixtureId: string; idempotentReplay: boolean }> {
|
|
388
|
+
const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.create", session.actor.id);
|
|
389
|
+
if (replay) return { fixtureId: String(replay.fixtureId), idempotentReplay: true };
|
|
390
|
+
if (!await db.prepare(`SELECT id FROM workflow_definitions WHERE id = ?1 AND status != 'archived'`).bind(workflowId).first()) {
|
|
391
|
+
throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
|
|
392
|
+
}
|
|
393
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 _.-]{1,119}$/u.test(input.name)) throw new ApiError(400, "workflow_fixture_name_invalid", "Fixture name must contain 2 to 120 safe characters.");
|
|
394
|
+
if (await db.prepare(`SELECT id FROM workflow_fixtures WHERE workflow_id = ?1 AND name = ?2`).bind(workflowId, input.name).first()) {
|
|
395
|
+
throw new ApiError(409, "workflow_fixture_exists", "A fixture with this name already exists.");
|
|
396
|
+
}
|
|
397
|
+
const fixtureId = crypto.randomUUID();
|
|
398
|
+
const createdAt = now();
|
|
399
|
+
const response = { fixtureId };
|
|
400
|
+
await db.batch([
|
|
401
|
+
db.prepare(`
|
|
402
|
+
INSERT INTO workflow_fixtures (
|
|
403
|
+
id, workflow_id, name, input_json, component_results_json,
|
|
404
|
+
human_responses_json, expected_json, failure_injection_json,
|
|
405
|
+
created_by, created_at, updated_at
|
|
406
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)
|
|
407
|
+
`).bind(
|
|
408
|
+
fixtureId, workflowId, input.name, canonicalJson(input.input),
|
|
409
|
+
canonicalJson(input.componentResults), canonicalJson(input.humanResponses),
|
|
410
|
+
canonicalJson(input.expected), canonicalJson(input.failureInjection),
|
|
411
|
+
session.actor.id, createdAt,
|
|
412
|
+
),
|
|
413
|
+
db.prepare(`
|
|
414
|
+
INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
|
|
415
|
+
VALUES (?1, 'workflow.fixture.create', ?2, ?3, ?4, ?5)
|
|
416
|
+
`).bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), createdAt),
|
|
417
|
+
auditStatement(db, {
|
|
418
|
+
idempotencyKey: `workflow-fixture:${idempotencyKey}`,
|
|
419
|
+
action: "workflow.fixture_created",
|
|
420
|
+
session,
|
|
421
|
+
targetType: "workflow_fixture",
|
|
422
|
+
targetId: fixtureId,
|
|
423
|
+
details: { workflowId, name: input.name },
|
|
424
|
+
createdAt,
|
|
425
|
+
}),
|
|
426
|
+
]);
|
|
427
|
+
return { fixtureId, idempotentReplay: false };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function updateWorkflowFixture(
|
|
431
|
+
db: D1Database,
|
|
432
|
+
workflowId: string,
|
|
433
|
+
fixtureId: string,
|
|
434
|
+
input: {
|
|
435
|
+
name: string;
|
|
436
|
+
input: JsonObject;
|
|
437
|
+
componentResults: JsonObject;
|
|
438
|
+
humanResponses: JsonObject;
|
|
439
|
+
expected: JsonObject;
|
|
440
|
+
failureInjection: JsonObject;
|
|
441
|
+
},
|
|
442
|
+
session: OperatorSession,
|
|
443
|
+
idempotencyKey: string,
|
|
444
|
+
): Promise<{ fixtureId: string; idempotentReplay: boolean }> {
|
|
445
|
+
const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.update", session.actor.id);
|
|
446
|
+
if (replay) return { fixtureId: String(replay.fixtureId), idempotentReplay: true };
|
|
447
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 _.-]{1,119}$/u.test(input.name)) throw new ApiError(400, "workflow_fixture_name_invalid", "Fixture name must contain 2 to 120 safe characters.");
|
|
448
|
+
const fixture = await db.prepare(`SELECT id FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1`)
|
|
449
|
+
.bind(fixtureId, workflowId).first();
|
|
450
|
+
if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
|
|
451
|
+
const timestamp = now();
|
|
452
|
+
const response = { fixtureId };
|
|
453
|
+
await db.batch([
|
|
454
|
+
db.prepare(`
|
|
455
|
+
UPDATE workflow_fixtures SET name = ?3, input_json = ?4,
|
|
456
|
+
component_results_json = ?5, human_responses_json = ?6,
|
|
457
|
+
expected_json = ?7, failure_injection_json = ?8, updated_at = ?9
|
|
458
|
+
WHERE id = ?1 AND workflow_id = ?2
|
|
459
|
+
`).bind(
|
|
460
|
+
fixtureId, workflowId, input.name, canonicalJson(input.input),
|
|
461
|
+
canonicalJson(input.componentResults), canonicalJson(input.humanResponses),
|
|
462
|
+
canonicalJson(input.expected), canonicalJson(input.failureInjection), timestamp,
|
|
463
|
+
),
|
|
464
|
+
db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.fixture.update', ?2, ?3, ?4, ?5)`)
|
|
465
|
+
.bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), timestamp),
|
|
466
|
+
auditStatement(db, {
|
|
467
|
+
idempotencyKey: `workflow-fixture-update:${idempotencyKey}`,
|
|
468
|
+
action: "workflow.fixture_updated", session, targetType: "workflow_fixture",
|
|
469
|
+
targetId: fixtureId, details: { workflowId, name: input.name }, createdAt: timestamp,
|
|
470
|
+
}),
|
|
471
|
+
]);
|
|
472
|
+
return { fixtureId, idempotentReplay: false };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function deleteWorkflowFixture(
|
|
476
|
+
db: D1Database,
|
|
477
|
+
workflowId: string,
|
|
478
|
+
fixtureId: string,
|
|
479
|
+
session: OperatorSession,
|
|
480
|
+
idempotencyKey: string,
|
|
481
|
+
): Promise<{ fixtureId: string; deleted: true; idempotentReplay: boolean }> {
|
|
482
|
+
const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.delete", session.actor.id);
|
|
483
|
+
if (replay) return { fixtureId: String(replay.fixtureId), deleted: true, idempotentReplay: true };
|
|
484
|
+
const fixture = await db.prepare(`SELECT name FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1`)
|
|
485
|
+
.bind(fixtureId, workflowId).first<{ name: string }>();
|
|
486
|
+
if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
|
|
487
|
+
const timestamp = now();
|
|
488
|
+
const response = { fixtureId, deleted: true as const };
|
|
489
|
+
await db.batch([
|
|
490
|
+
db.prepare(`DELETE FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2`).bind(fixtureId, workflowId),
|
|
491
|
+
db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.fixture.delete', ?2, ?3, ?4, ?5)`)
|
|
492
|
+
.bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), timestamp),
|
|
493
|
+
auditStatement(db, {
|
|
494
|
+
idempotencyKey: `workflow-fixture-delete:${idempotencyKey}`,
|
|
495
|
+
action: "workflow.fixture_deleted", session, targetType: "workflow_fixture",
|
|
496
|
+
targetId: fixtureId, details: { workflowId, name: fixture.name }, createdAt: timestamp,
|
|
497
|
+
}),
|
|
498
|
+
]);
|
|
499
|
+
return { ...response, idempotentReplay: false };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function runWorkflowFixtureTest(
|
|
503
|
+
db: D1Database,
|
|
504
|
+
workflowId: string,
|
|
505
|
+
fixtureId: string,
|
|
506
|
+
session: OperatorSession,
|
|
507
|
+
): Promise<{ testRunId: string; status: "passed" | "failed"; graphDigest: string; result: Record<string, unknown> }> {
|
|
508
|
+
const [draft, fixture] = await Promise.all([
|
|
509
|
+
db.prepare(`SELECT graph_json AS graphJson, graph_digest AS graphDigest FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1`)
|
|
510
|
+
.bind(workflowId).first<{ graphJson: string; graphDigest: string }>(),
|
|
511
|
+
db.prepare(`
|
|
512
|
+
SELECT input_json AS inputJson, component_results_json AS componentResultsJson,
|
|
513
|
+
human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
|
|
514
|
+
failure_injection_json AS failureInjectionJson
|
|
515
|
+
FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
|
|
516
|
+
`).bind(fixtureId, workflowId).first<{
|
|
517
|
+
inputJson: string; componentResultsJson: string; humanResponsesJson: string;
|
|
518
|
+
expectedJson: string; failureInjectionJson: string;
|
|
519
|
+
}>(),
|
|
520
|
+
]);
|
|
521
|
+
if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow draft does not exist.");
|
|
522
|
+
if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
|
|
523
|
+
const graph = parseWorkflowGraph(parsedJson(draft.graphJson, null));
|
|
524
|
+
const simulation = simulateWorkflow(graph, {
|
|
525
|
+
input: parsedJson(fixture.inputJson, {}),
|
|
526
|
+
componentResults: parsedJson(fixture.componentResultsJson, {}),
|
|
527
|
+
humanResponses: parsedJson(fixture.humanResponsesJson, {}),
|
|
528
|
+
failureInjection: parsedJson(fixture.failureInjectionJson, {}),
|
|
529
|
+
});
|
|
530
|
+
const expected = parsedJson<JsonObject>(fixture.expectedJson, {});
|
|
531
|
+
const passed = simulation.status === "passed" && simulationMatchesExpected(simulation, expected);
|
|
532
|
+
const status = passed ? "passed" : "failed";
|
|
533
|
+
const testRunId = crypto.randomUUID();
|
|
534
|
+
const timestamp = now();
|
|
535
|
+
const result = {
|
|
536
|
+
status,
|
|
537
|
+
terminalCode: simulation.terminalCode,
|
|
538
|
+
steps: simulation.steps,
|
|
539
|
+
expectedMatched: passed,
|
|
540
|
+
resultDigest: `sha256:${await sha256(simulation.canonicalResult)}`,
|
|
541
|
+
};
|
|
542
|
+
await db.prepare(`
|
|
543
|
+
INSERT INTO workflow_test_runs (
|
|
544
|
+
id, workflow_id, fixture_id, graph_digest, mode, status,
|
|
545
|
+
result_json, error_json, started_by, started_at, finished_at
|
|
546
|
+
) VALUES (?1, ?2, ?3, ?4, 'simulation', ?5, ?6, ?7, ?8, ?9, ?9)
|
|
547
|
+
`).bind(
|
|
548
|
+
testRunId, workflowId, fixtureId, draft.graphDigest, status,
|
|
549
|
+
canonicalJson(result), simulation.error ? canonicalJson(simulation.error) : null,
|
|
550
|
+
session.actor.id, timestamp,
|
|
551
|
+
).run();
|
|
552
|
+
return { testRunId, status, graphDigest: draft.graphDigest, result };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export async function loadWorkflowTestRun(
|
|
556
|
+
db: D1Database,
|
|
557
|
+
workflowId: string,
|
|
558
|
+
testRunId: string,
|
|
559
|
+
): Promise<Record<string, unknown> | null> {
|
|
560
|
+
const row = await db.prepare(`
|
|
561
|
+
SELECT id, workflow_id AS workflowId, workflow_version_id AS workflowVersionId,
|
|
562
|
+
fixture_id AS fixtureId, graph_digest AS graphDigest, mode, status,
|
|
563
|
+
result_json AS resultJson, error_json AS errorJson, started_by AS startedBy,
|
|
564
|
+
started_at AS startedAt, finished_at AS finishedAt
|
|
565
|
+
FROM workflow_test_runs WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
|
|
566
|
+
`).bind(testRunId, workflowId).first<Record<string, unknown>>();
|
|
567
|
+
if (!row) return null;
|
|
568
|
+
return {
|
|
569
|
+
...row,
|
|
570
|
+
result: parsedJson(String(row.resultJson ?? ""), null),
|
|
571
|
+
error: parsedJson(String(row.errorJson ?? ""), null),
|
|
572
|
+
resultJson: undefined,
|
|
573
|
+
errorJson: undefined,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export async function loadWorkflowVersion(
|
|
578
|
+
db: D1Database,
|
|
579
|
+
workflowId: string,
|
|
580
|
+
versionId: string,
|
|
581
|
+
): Promise<Record<string, unknown> | null> {
|
|
582
|
+
const row = await db.prepare(`
|
|
583
|
+
SELECT v.id, v.workflow_id AS workflowId, v.version, v.graph_json AS graphJson,
|
|
584
|
+
v.graph_digest AS graphDigest, v.input_schema_json AS inputSchemaJson,
|
|
585
|
+
v.output_schema_json AS outputSchemaJson, v.authority_mode AS authorityMode,
|
|
586
|
+
v.dependency_manifest_json AS dependencyManifestJson,
|
|
587
|
+
v.capability_manifest_json AS capabilityManifestJson,
|
|
588
|
+
v.artifact_id AS artifactId, v.compiler_version AS compilerVersion,
|
|
589
|
+
v.test_evidence_json AS testEvidenceJson, v.published_by AS publishedBy,
|
|
590
|
+
v.published_at AS publishedAt, v.retired_at AS retiredAt,
|
|
591
|
+
a.bundle_digest AS bundleDigest, a.manifest_digest AS manifestDigest,
|
|
592
|
+
a.status AS artifactStatus, a.compatibility_date AS compatibilityDate
|
|
593
|
+
FROM workflow_versions v JOIN workflow_artifacts a ON a.id = v.artifact_id
|
|
594
|
+
WHERE v.id = ?1 AND v.workflow_id = ?2 LIMIT 1
|
|
595
|
+
`).bind(versionId, workflowId).first<Record<string, unknown>>();
|
|
596
|
+
if (!row) return null;
|
|
597
|
+
return {
|
|
598
|
+
...row,
|
|
599
|
+
graph: parsedJson(String(row.graphJson ?? ""), null),
|
|
600
|
+
inputSchema: parsedJson(String(row.inputSchemaJson ?? ""), {}),
|
|
601
|
+
outputSchema: parsedJson(String(row.outputSchemaJson ?? ""), {}),
|
|
602
|
+
dependencyManifest: parsedJson(String(row.dependencyManifestJson ?? ""), []),
|
|
603
|
+
capabilityManifest: parsedJson(String(row.capabilityManifestJson ?? ""), {}),
|
|
604
|
+
testEvidence: parsedJson(String(row.testEvidenceJson ?? ""), {}),
|
|
605
|
+
graphJson: undefined, inputSchemaJson: undefined, outputSchemaJson: undefined,
|
|
606
|
+
dependencyManifestJson: undefined, capabilityManifestJson: undefined, testEvidenceJson: undefined,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export async function diffWorkflowVersion(
|
|
611
|
+
db: D1Database,
|
|
612
|
+
workflowId: string,
|
|
613
|
+
versionId: string,
|
|
614
|
+
againstVersionId?: string,
|
|
615
|
+
): Promise<Record<string, unknown>> {
|
|
616
|
+
const target = await loadWorkflowVersion(db, workflowId, versionId);
|
|
617
|
+
if (!target) throw new ApiError(404, "workflow_version_not_found", "The workflow version does not exist.");
|
|
618
|
+
let againstId = againstVersionId;
|
|
619
|
+
if (!againstId) {
|
|
620
|
+
const prior = await db.prepare(`
|
|
621
|
+
SELECT id FROM workflow_versions WHERE workflow_id = ?1 AND version < ?2
|
|
622
|
+
ORDER BY version DESC, id DESC LIMIT 1
|
|
623
|
+
`).bind(workflowId, Number(target.version)).first<{ id: string }>();
|
|
624
|
+
againstId = prior?.id;
|
|
625
|
+
}
|
|
626
|
+
const against = againstId ? await loadWorkflowVersion(db, workflowId, againstId) : null;
|
|
627
|
+
const targetGraph = target.graph as WorkflowGraphV1;
|
|
628
|
+
const againstGraph = against?.graph as WorkflowGraphV1 | undefined;
|
|
629
|
+
const targetNodes = new Map((targetGraph?.nodes ?? []).map((node) => [node.id, canonicalJson(node)]));
|
|
630
|
+
const againstNodes = new Map((againstGraph?.nodes ?? []).map((node) => [node.id, canonicalJson(node)]));
|
|
631
|
+
const added = [...targetNodes.keys()].filter((id) => !againstNodes.has(id));
|
|
632
|
+
const removed = [...againstNodes.keys()].filter((id) => !targetNodes.has(id));
|
|
633
|
+
const changed = [...targetNodes.keys()].filter((id) => againstNodes.has(id) && againstNodes.get(id) !== targetNodes.get(id));
|
|
634
|
+
return {
|
|
635
|
+
workflowId,
|
|
636
|
+
versionId,
|
|
637
|
+
againstVersionId: against?.id ?? null,
|
|
638
|
+
graphDigestChanged: target.graphDigest !== against?.graphDigest,
|
|
639
|
+
nodes: { added, removed, changed },
|
|
640
|
+
authorityMode: { from: against?.authorityMode ?? null, to: target.authorityMode },
|
|
641
|
+
dependencies: { from: against?.dependencyManifest ?? [], to: target.dependencyManifest ?? [] },
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function retireWorkflowVersion(
|
|
646
|
+
db: D1Database,
|
|
647
|
+
workflowId: string,
|
|
648
|
+
versionId: string,
|
|
649
|
+
session: OperatorSession,
|
|
650
|
+
idempotencyKey: string,
|
|
651
|
+
): Promise<{ workflowId: string; versionId: string; retiredAt: string; idempotentReplay: boolean }> {
|
|
652
|
+
const replay = await mutationReplay(db, idempotencyKey, "workflow.version.retire", session.actor.id);
|
|
653
|
+
if (replay) return {
|
|
654
|
+
workflowId: String(replay.workflowId), versionId: String(replay.versionId),
|
|
655
|
+
retiredAt: String(replay.retiredAt), idempotentReplay: true,
|
|
656
|
+
};
|
|
657
|
+
const version = await db.prepare(`
|
|
658
|
+
SELECT v.id, v.retired_at AS retiredAt, w.active_version_id AS activeVersionId
|
|
659
|
+
FROM workflow_versions v JOIN workflow_definitions w ON w.id = v.workflow_id
|
|
660
|
+
WHERE v.id = ?1 AND v.workflow_id = ?2 LIMIT 1
|
|
661
|
+
`).bind(versionId, workflowId).first<{ id: string; retiredAt: string | null; activeVersionId: string | null }>();
|
|
662
|
+
if (!version) throw new ApiError(404, "workflow_version_not_found", "The workflow version does not exist.");
|
|
663
|
+
if (version.retiredAt) return { workflowId, versionId, retiredAt: version.retiredAt, idempotentReplay: true };
|
|
664
|
+
if (version.activeVersionId === versionId) throw new ApiError(409, "workflow_active_version_cannot_retire", "Publish or select a replacement before retiring the active version.");
|
|
665
|
+
const dependent = await db.prepare(`
|
|
666
|
+
SELECT
|
|
667
|
+
EXISTS(SELECT 1 FROM queue_workflow_pins WHERE workflow_version_id = ?1 AND deactivated_at IS NULL) AS pinned,
|
|
668
|
+
EXISTS(SELECT 1 FROM workflow_runs WHERE workflow_version_id = ?1 AND state IN ('created','dispatch_pending','queued','running','waiting','paused','repair_required')) AS running
|
|
669
|
+
`).bind(versionId).first<{ pinned: number; running: number }>();
|
|
670
|
+
if (dependent?.pinned || dependent?.running) throw new ApiError(409, "workflow_version_in_use", "Active pins or runs still depend on this workflow version.");
|
|
671
|
+
const retiredAt = now();
|
|
672
|
+
const response = { workflowId, versionId, retiredAt };
|
|
673
|
+
await db.batch([
|
|
674
|
+
db.prepare(`UPDATE workflow_versions SET retired_at = ?2 WHERE id = ?1 AND retired_at IS NULL`).bind(versionId, retiredAt),
|
|
675
|
+
db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.version.retire', ?2, ?3, ?4, ?5)`)
|
|
676
|
+
.bind(idempotencyKey, session.actor.id, versionId, canonicalJson(response), retiredAt),
|
|
677
|
+
auditStatement(db, {
|
|
678
|
+
idempotencyKey: `workflow-version-retire:${idempotencyKey}`,
|
|
679
|
+
action: "workflow.version_retired", session, targetType: "workflow_version",
|
|
680
|
+
targetId: versionId, details: { workflowId }, createdAt: retiredAt,
|
|
681
|
+
}),
|
|
682
|
+
]);
|
|
683
|
+
return { ...response, idempotentReplay: false };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function dependencyAvailable(db: D1Database, dependency: WorkflowDependencyReference): Promise<boolean> {
|
|
687
|
+
const queryByType: Record<WorkflowDependencyReference["type"], string> = {
|
|
688
|
+
component: `SELECT v.id FROM component_versions v JOIN component_definitions d ON d.id = v.definition_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND d.status IN ('active','deprecated')`,
|
|
689
|
+
connection: `SELECT v.id FROM connection_versions v JOIN connection_definitions d ON d.id = v.connection_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND d.status = 'active'`,
|
|
690
|
+
policy: `SELECT id FROM policy_versions WHERE id = ?1 AND published_at IS NOT NULL`,
|
|
691
|
+
template: `SELECT id FROM message_template_versions WHERE id = ?1 AND published_at IS NOT NULL`,
|
|
692
|
+
model_alias: `SELECT v.id FROM model_alias_versions v JOIN model_aliases a ON a.id = v.alias_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND a.status = 'active'`,
|
|
693
|
+
agent: `SELECT v.id FROM agent_versions v JOIN agent_definitions a ON a.id = v.agent_id WHERE v.id = ?1 AND v.published_at IS NOT NULL AND v.retired_at IS NULL AND a.status = 'active'`,
|
|
694
|
+
workflow: `SELECT v.id FROM workflow_versions v JOIN workflow_artifacts a ON a.id = v.artifact_id WHERE v.id = ?1 AND v.retired_at IS NULL AND a.status = 'ready'`,
|
|
695
|
+
};
|
|
696
|
+
return Boolean(await db.prepare(queryByType[dependency.type]).bind(dependency.versionId).first());
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function requireDependencies(db: D1Database, dependencies: WorkflowDependencyReference[]): Promise<void> {
|
|
700
|
+
const availability = await Promise.all(dependencies.map(async (dependency) => ({
|
|
701
|
+
dependency,
|
|
702
|
+
available: await dependencyAvailable(db, dependency),
|
|
703
|
+
})));
|
|
704
|
+
const missing = availability.filter((entry) => !entry.available).map((entry) => entry.dependency);
|
|
705
|
+
if (missing.length) throw new ApiError(409, "workflow_dependency_unavailable", "The workflow references unpublished, paused, retired, missing, or incompatible dependencies.", { dependencies: missing });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function persistArtifact(
|
|
709
|
+
db: D1Database,
|
|
710
|
+
bucket: R2Bucket,
|
|
711
|
+
compiled: Awaited<ReturnType<typeof compileWorkflowGraph>>,
|
|
712
|
+
): Promise<string> {
|
|
713
|
+
const existing = await db.prepare(`
|
|
714
|
+
SELECT id, status FROM workflow_artifacts
|
|
715
|
+
WHERE installation_id = 'default' AND bundle_digest = ?1 LIMIT 1
|
|
716
|
+
`).bind(compiled.bundleDigest).first<{ id: string; status: string }>();
|
|
717
|
+
if (existing?.status === "ready") return existing.id;
|
|
718
|
+
if (existing) throw new ApiError(409, "workflow_artifact_unavailable", "An artifact with this digest exists but is not ready.");
|
|
719
|
+
const artifactId = crypto.randomUUID();
|
|
720
|
+
const objectKey = `workflow-artifacts/${compiled.bundleDigest.slice("sha256:".length, "sha256:".length + 2)}/${compiled.bundleDigest.slice("sha256:".length)}.mjs`;
|
|
721
|
+
await bucket.put(objectKey, compiled.source, {
|
|
722
|
+
httpMetadata: { contentType: "text/javascript; charset=utf-8" },
|
|
723
|
+
customMetadata: {
|
|
724
|
+
compiler: compiled.compilerVersion,
|
|
725
|
+
graphDigest: compiled.graphDigest,
|
|
726
|
+
bundleDigest: compiled.bundleDigest,
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
const readBack = await bucket.get(objectKey);
|
|
730
|
+
if (!readBack) throw new ApiError(503, "workflow_artifact_write_failed", "The compiled workflow artifact could not be read back.");
|
|
731
|
+
const readBackSource = await readBack.text();
|
|
732
|
+
if (`sha256:${await sha256(readBackSource)}` !== compiled.bundleDigest) {
|
|
733
|
+
throw new ApiError(503, "workflow_artifact_digest_mismatch", "The compiled workflow artifact failed digest verification.");
|
|
734
|
+
}
|
|
735
|
+
const createdAt = now();
|
|
736
|
+
await db.prepare(`
|
|
737
|
+
INSERT INTO workflow_artifacts (
|
|
738
|
+
id, installation_id, compiler_version, compatibility_date, graph_digest,
|
|
739
|
+
source_digest, bundle_digest, manifest_digest, bundle_r2_key, bundle_size_bytes,
|
|
740
|
+
capability_manifest_json, compiler_warnings_json, status, created_at, verified_at
|
|
741
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'ready', ?12, ?12)
|
|
742
|
+
`).bind(
|
|
743
|
+
artifactId, compiled.compilerVersion, compiled.compatibilityDate, compiled.graphDigest,
|
|
744
|
+
compiled.sourceDigest, compiled.bundleDigest, compiled.manifestDigest, objectKey,
|
|
745
|
+
new TextEncoder().encode(compiled.source).byteLength,
|
|
746
|
+
canonicalJson(compiled.capabilityManifest), canonicalJson(compiled.warnings), createdAt,
|
|
747
|
+
).run();
|
|
748
|
+
return artifactId;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export async function publishWorkflowDraft(
|
|
752
|
+
db: D1Database,
|
|
753
|
+
bucket: R2Bucket,
|
|
754
|
+
workflowId: string,
|
|
755
|
+
input: WorkflowPublishInput,
|
|
756
|
+
session: OperatorSession,
|
|
757
|
+
idempotencyKey: string,
|
|
758
|
+
compatibilityDate: string,
|
|
759
|
+
): Promise<{ workflowId: string; versionId: string; version: number; artifactId: string; bundleDigest: string; idempotentReplay: boolean }> {
|
|
760
|
+
const replay = await mutationReplay(db, idempotencyKey, "workflow.publish", session.actor.id);
|
|
761
|
+
if (replay) return {
|
|
762
|
+
workflowId: String(replay.workflowId), versionId: String(replay.versionId), version: Number(replay.version),
|
|
763
|
+
artifactId: String(replay.artifactId), bundleDigest: String(replay.bundleDigest), idempotentReplay: true,
|
|
764
|
+
};
|
|
765
|
+
const workflow = await db.prepare(`
|
|
766
|
+
SELECT w.status, w.purpose, d.id AS draft_id, d.graph_json, d.revision
|
|
767
|
+
FROM workflow_definitions w JOIN workflow_drafts d ON d.workflow_id = w.id
|
|
768
|
+
WHERE w.id = ?1 LIMIT 1
|
|
769
|
+
`).bind(workflowId).first<{ status: string; purpose: string; draft_id: string; graph_json: string; revision: number }>();
|
|
770
|
+
if (!workflow) throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
|
|
771
|
+
if (workflow.status !== "active") throw new ApiError(409, "workflow_not_active", "Only an active workflow can be published.");
|
|
772
|
+
if (workflow.revision !== input.expectedRevision) throw new ApiError(409, "workflow_revision_conflict", "The workflow draft changed after validation.", {
|
|
773
|
+
expected_revision: input.expectedRevision, current_revision: workflow.revision,
|
|
774
|
+
});
|
|
775
|
+
const graph = parseWorkflowGraph(parsedJson(workflow.graph_json, null));
|
|
776
|
+
if (graph.purpose !== workflow.purpose) throw new ApiError(409, "workflow_purpose_mismatch", "The stored workflow definition and graph purposes do not match.");
|
|
777
|
+
const graphDigest = `sha256:${await sha256(canonicalWorkflowGraph(graph))}`;
|
|
778
|
+
const testRunId = typeof input.testEvidence.test_run_id === "string" ? input.testEvidence.test_run_id : null;
|
|
779
|
+
const testRun = testRunId ? await db.prepare(`
|
|
780
|
+
SELECT id, graph_digest AS graphDigest, status FROM workflow_test_runs
|
|
781
|
+
WHERE id = ?1 AND workflow_id = ?2 AND mode = 'simulation' LIMIT 1
|
|
782
|
+
`).bind(testRunId, workflowId).first<{ id: string; graphDigest: string; status: string }>() : null;
|
|
783
|
+
if (!testRun || testRun.status !== "passed" || testRun.graphDigest !== graphDigest) {
|
|
784
|
+
throw new ApiError(409, "workflow_tests_required", "A passing server-recorded fixture test for this exact draft is required before publication.");
|
|
785
|
+
}
|
|
786
|
+
const validation = validateWorkflowGraph(graph, input.authorityMode);
|
|
787
|
+
if (!validation.valid) throw new ApiError(400, "workflow_validation_failed", "The workflow draft has publication errors.", { errors: validation.errors, warnings: validation.warnings });
|
|
788
|
+
await requireDependencies(db, validation.dependencies);
|
|
789
|
+
const compiled = await compileWorkflowGraph(graph, input.authorityMode, compatibilityDate);
|
|
790
|
+
const artifactId = await persistArtifact(db, bucket, compiled);
|
|
791
|
+
const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM workflow_versions WHERE workflow_id = ?1`)
|
|
792
|
+
.bind(workflowId).first<{ version: number }>();
|
|
793
|
+
const version = Number(latest?.version ?? 0) + 1;
|
|
794
|
+
const versionId = crypto.randomUUID();
|
|
795
|
+
const publishedAt = now();
|
|
796
|
+
const response = { workflowId, versionId, version, artifactId, bundleDigest: compiled.bundleDigest };
|
|
797
|
+
const statements: D1PreparedStatement[] = [
|
|
798
|
+
db.prepare(`
|
|
799
|
+
INSERT INTO workflow_versions (
|
|
800
|
+
id, workflow_id, version, graph_json, graph_digest, input_schema_json,
|
|
801
|
+
output_schema_json, authority_mode, dependency_manifest_json, capability_manifest_json,
|
|
802
|
+
artifact_id, compiler_version, test_evidence_json, published_by, published_at
|
|
803
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, '{"type":"object"}', ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
|
804
|
+
`).bind(
|
|
805
|
+
versionId, workflowId, version, canonicalWorkflowGraph(graph), compiled.graphDigest,
|
|
806
|
+
canonicalJson(graph.inputSchema), input.authorityMode, canonicalJson(compiled.dependencyManifest),
|
|
807
|
+
canonicalJson(compiled.capabilityManifest), artifactId, compiled.compilerVersion,
|
|
808
|
+
canonicalJson({ test_run_id: testRun.id, passed: true, graph_digest: testRun.graphDigest }), session.actor.id, publishedAt,
|
|
809
|
+
),
|
|
810
|
+
db.prepare(`UPDATE workflow_definitions SET active_version_id = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'active'`)
|
|
811
|
+
.bind(workflowId, versionId, publishedAt),
|
|
812
|
+
db.prepare(`
|
|
813
|
+
UPDATE workflow_drafts SET validation_json = ?2, validation_digest = ?3,
|
|
814
|
+
validation_status = 'valid', updated_at = ?4 WHERE id = ?1 AND revision = ?5
|
|
815
|
+
`).bind(
|
|
816
|
+
workflow.draft_id, canonicalJson(validation), `sha256:${await sha256(canonicalJson(validation))}`,
|
|
817
|
+
publishedAt, workflow.revision,
|
|
818
|
+
),
|
|
819
|
+
db.prepare(`
|
|
820
|
+
INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
|
|
821
|
+
VALUES (?1, 'workflow.publish', ?2, ?3, ?4, ?5)
|
|
822
|
+
`).bind(idempotencyKey, session.actor.id, versionId, canonicalJson(response), publishedAt),
|
|
823
|
+
auditStatement(db, {
|
|
824
|
+
idempotencyKey: `workflow-publish:${idempotencyKey}`, action: "workflow.version_published", session,
|
|
825
|
+
targetType: "workflow", targetId: workflowId,
|
|
826
|
+
details: { versionId, version, artifactId, bundleDigest: compiled.bundleDigest, authorityMode: input.authorityMode },
|
|
827
|
+
createdAt: publishedAt,
|
|
828
|
+
}),
|
|
829
|
+
];
|
|
830
|
+
for (const dependency of compiled.dependencyManifest) {
|
|
831
|
+
statements.push(db.prepare(`
|
|
832
|
+
INSERT INTO workflow_dependencies (
|
|
833
|
+
workflow_version_id, dependency_type, dependency_key, dependency_version_id
|
|
834
|
+
) VALUES (?1, ?2, ?3, ?4)
|
|
835
|
+
`).bind(versionId, dependency.type, dependency.key, dependency.versionId));
|
|
836
|
+
}
|
|
837
|
+
await db.batch(statements);
|
|
838
|
+
return { ...response, idempotentReplay: false };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export async function loadQueueWorkflowPins(db: D1Database, queueId: string): Promise<Record<string, unknown>[]> {
|
|
842
|
+
const rows = await db.prepare(`
|
|
843
|
+
SELECT p.id, p.queue_id AS queueId, p.queue_version_id AS queueVersionId,
|
|
844
|
+
p.workflow_version_id AS workflowVersionId, p.mode, p.rollback_of_pin_id AS rollbackOfPinId,
|
|
845
|
+
p.activated_by AS activatedBy, p.activated_at AS activatedAt,
|
|
846
|
+
p.deactivated_at AS deactivatedAt, w.workflow_key AS workflowKey,
|
|
847
|
+
w.name AS workflowName, v.version, v.authority_mode AS authorityMode,
|
|
848
|
+
a.bundle_digest AS bundleDigest, a.status AS artifactStatus
|
|
849
|
+
FROM queue_workflow_pins p
|
|
850
|
+
JOIN workflow_versions v ON v.id = p.workflow_version_id
|
|
851
|
+
JOIN workflow_definitions w ON w.id = v.workflow_id
|
|
852
|
+
JOIN workflow_artifacts a ON a.id = v.artifact_id
|
|
853
|
+
WHERE p.queue_id = ?1
|
|
854
|
+
ORDER BY (p.deactivated_at IS NULL) DESC, p.activated_at DESC, p.id DESC
|
|
855
|
+
`).bind(queueId).all<Record<string, unknown>>();
|
|
856
|
+
return rows.results;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export async function pinQueueWorkflow(
|
|
860
|
+
db: D1Database,
|
|
861
|
+
queueId: string,
|
|
862
|
+
input: { workflowVersionId: string; mode: "shadow" | "primary"; rollbackOfPinId?: string },
|
|
863
|
+
session: OperatorSession,
|
|
864
|
+
idempotencyKey: string,
|
|
865
|
+
): Promise<{ pinId: string; workflowVersionId: string; mode: string; idempotentReplay: boolean }> {
|
|
866
|
+
const replay = await mutationReplay(db, idempotencyKey, "queue.workflow_pin", session.actor.id);
|
|
867
|
+
if (replay) return {
|
|
868
|
+
pinId: String(replay.pinId), workflowVersionId: String(replay.workflowVersionId), mode: String(replay.mode), idempotentReplay: true,
|
|
869
|
+
};
|
|
870
|
+
const target = await db.prepare(`
|
|
871
|
+
SELECT q.id AS queue_id, q.active_version_id AS queue_version_id,
|
|
872
|
+
v.id AS workflow_version_id, v.authority_mode, a.status AS artifact_status
|
|
873
|
+
FROM queue_definitions q CROSS JOIN workflow_versions v
|
|
874
|
+
JOIN workflow_artifacts a ON a.id = v.artifact_id
|
|
875
|
+
WHERE q.id = ?1 AND q.status = 'active' AND v.id = ?2 AND v.retired_at IS NULL
|
|
876
|
+
LIMIT 1
|
|
877
|
+
`).bind(queueId, input.workflowVersionId).first<{
|
|
878
|
+
queue_id: string; queue_version_id: string | null; workflow_version_id: string;
|
|
879
|
+
authority_mode: WorkflowAuthorityMode; artifact_status: string;
|
|
880
|
+
}>();
|
|
881
|
+
if (!target) throw new ApiError(404, "queue_or_workflow_not_found", "The active queue or workflow version does not exist.");
|
|
882
|
+
if (target.artifact_status !== "ready") throw new ApiError(409, "workflow_artifact_not_ready", "The workflow artifact is not verified and ready.");
|
|
883
|
+
if (input.mode === "primary" && target.authority_mode === "shadow") throw new ApiError(409, "shadow_workflow_not_primary", "A shadow-authority workflow cannot be pinned as primary.");
|
|
884
|
+
if (input.mode === "shadow" && target.authority_mode !== "shadow") throw new ApiError(409, "shadow_pin_authority_required", "A shadow pin must reference a version compiled with shadow authority.");
|
|
885
|
+
const current = await db.prepare(`
|
|
886
|
+
SELECT id FROM queue_workflow_pins WHERE queue_id = ?1 AND mode = ?2 AND deactivated_at IS NULL LIMIT 1
|
|
887
|
+
`).bind(queueId, input.mode).first<{ id: string }>();
|
|
888
|
+
const pinId = crypto.randomUUID();
|
|
889
|
+
const activatedAt = now();
|
|
890
|
+
const response = { pinId, workflowVersionId: input.workflowVersionId, mode: input.mode };
|
|
891
|
+
const statements: D1PreparedStatement[] = [];
|
|
892
|
+
if (current) statements.push(db.prepare(`UPDATE queue_workflow_pins SET deactivated_at = ?2 WHERE id = ?1 AND deactivated_at IS NULL`).bind(current.id, activatedAt));
|
|
893
|
+
statements.push(
|
|
894
|
+
db.prepare(`
|
|
895
|
+
INSERT INTO queue_workflow_pins (
|
|
896
|
+
id, queue_id, queue_version_id, workflow_version_id, mode,
|
|
897
|
+
rollback_of_pin_id, activated_by, activated_at
|
|
898
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
899
|
+
`).bind(
|
|
900
|
+
pinId, queueId, target.queue_version_id, input.workflowVersionId, input.mode,
|
|
901
|
+
input.rollbackOfPinId ?? current?.id ?? null, session.actor.id, activatedAt,
|
|
902
|
+
),
|
|
903
|
+
db.prepare(`
|
|
904
|
+
INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
|
|
905
|
+
VALUES (?1, 'queue.workflow_pin', ?2, ?3, ?4, ?5)
|
|
906
|
+
`).bind(idempotencyKey, session.actor.id, pinId, canonicalJson(response), activatedAt),
|
|
907
|
+
auditStatement(db, {
|
|
908
|
+
idempotencyKey: `queue-workflow-pin:${idempotencyKey}`, action: "queue.workflow_pinned", session,
|
|
909
|
+
targetType: "queue", targetId: queueId,
|
|
910
|
+
details: { pinId, workflowVersionId: input.workflowVersionId, mode: input.mode, priorPinId: current?.id },
|
|
911
|
+
createdAt: activatedAt,
|
|
912
|
+
}),
|
|
913
|
+
);
|
|
914
|
+
await db.batch(statements);
|
|
915
|
+
return { ...response, idempotentReplay: false };
|
|
916
|
+
}
|