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,540 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { ApiError } from "./report-http";
|
|
3
|
+
import type {
|
|
4
|
+
JsonObject,
|
|
5
|
+
JsonValue,
|
|
6
|
+
WorkflowAuthorityMode,
|
|
7
|
+
WorkflowCapabilityManifest,
|
|
8
|
+
WorkflowDefinitionInput,
|
|
9
|
+
WorkflowDependencyReference,
|
|
10
|
+
WorkflowEdgeV1,
|
|
11
|
+
WorkflowGraphV1,
|
|
12
|
+
WorkflowNodeKind,
|
|
13
|
+
WorkflowNodeV1,
|
|
14
|
+
WorkflowPublishInput,
|
|
15
|
+
WorkflowValidationIssue,
|
|
16
|
+
WorkflowValidationResult,
|
|
17
|
+
} from "./workflow-platform-types";
|
|
18
|
+
|
|
19
|
+
const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/u;
|
|
20
|
+
const keyPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
21
|
+
const versionReferencePattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u;
|
|
22
|
+
const eventTypePattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/u;
|
|
23
|
+
const prohibitedExpressionPattern = /(?:__proto__|prototype|constructor|\beval\b|\bFunction\b|javascript:|\bfetch\b|\bimport\s*\(|\brequire\s*\()/u;
|
|
24
|
+
const nodeKinds = new Set<WorkflowNodeKind>([
|
|
25
|
+
"start", "enrichment", "parallel_group", "join", "condition", "ai_proposal",
|
|
26
|
+
"human_task", "send_message", "wait_for_event", "delay", "delay_until",
|
|
27
|
+
"decision", "action", "handoff", "start_linked_workflow", "end",
|
|
28
|
+
]);
|
|
29
|
+
const purposes = new Set(["report", "appeal", "quality", "operations"] as const);
|
|
30
|
+
const authorityModes = new Set<WorkflowAuthorityMode>(["off", "shadow", "assist", "bounded_auto"]);
|
|
31
|
+
const dangerousKeys = new Set(["__proto__", "prototype", "constructor"]);
|
|
32
|
+
|
|
33
|
+
function object(value: unknown, path: string): Record<string, unknown> {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
35
|
+
throw new ApiError(400, "invalid_workflow", `${path} must be an object.`, { path });
|
|
36
|
+
}
|
|
37
|
+
return value as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function knownFields(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
|
41
|
+
const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
|
|
42
|
+
if (unexpected) throw new ApiError(400, "invalid_workflow", `${path} contains unsupported field ${unexpected}.`, { path: `${path}.${unexpected}` });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function text(value: unknown, path: string, minimum: number, maximum: number): string {
|
|
46
|
+
if (typeof value !== "string" || value.trim().length < minimum || value.trim().length > maximum) {
|
|
47
|
+
throw new ApiError(400, "invalid_workflow", `${path} must contain ${minimum} to ${maximum} characters.`, { path });
|
|
48
|
+
}
|
|
49
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new ApiError(400, "invalid_workflow", `${path} contains control characters.`, { path });
|
|
50
|
+
return value.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function identifier(value: unknown, path: string): string {
|
|
54
|
+
const result = text(value, path, 1, 100);
|
|
55
|
+
if (!identifierPattern.test(result)) throw new ApiError(400, "invalid_workflow", `${path} must use letters, digits, underscore, or hyphen.`, { path });
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function optionalText(value: unknown, path: string, maximum: number): string | undefined {
|
|
60
|
+
if (value === undefined) return undefined;
|
|
61
|
+
return text(value, path, 1, maximum);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function jsonValue(value: unknown, path: string, depth = 0): JsonValue {
|
|
65
|
+
if (depth > 20) throw new ApiError(400, "invalid_workflow", `${path} is nested too deeply.`, { path });
|
|
66
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
67
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
if (value.length > 500) throw new ApiError(400, "invalid_workflow", `${path} contains too many values.`, { path });
|
|
70
|
+
return value.map((child, index) => jsonValue(child, `${path}[${index}]`, depth + 1));
|
|
71
|
+
}
|
|
72
|
+
if (value && typeof value === "object") {
|
|
73
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
74
|
+
if (entries.length > 500) throw new ApiError(400, "invalid_workflow", `${path} contains too many properties.`, { path });
|
|
75
|
+
const output: JsonObject = {};
|
|
76
|
+
for (const [key, child] of entries) {
|
|
77
|
+
if (dangerousKeys.has(key)) throw new ApiError(400, "invalid_workflow", `${path} contains a prohibited property.`, { path: `${path}.${key}` });
|
|
78
|
+
if (key.length < 1 || key.length > 200) throw new ApiError(400, "invalid_workflow", `${path} contains an invalid property name.`, { path });
|
|
79
|
+
output[key] = jsonValue(child, `${path}.${key}`, depth + 1);
|
|
80
|
+
}
|
|
81
|
+
return output;
|
|
82
|
+
}
|
|
83
|
+
throw new ApiError(400, "invalid_workflow", `${path} must be JSON data.`, { path });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function jsonObject(value: unknown, path: string): JsonObject {
|
|
87
|
+
const parsed = jsonValue(value, path);
|
|
88
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
89
|
+
throw new ApiError(400, "invalid_workflow", `${path} must be an object.`, { path });
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseMapping(value: unknown, path: string): Record<string, JsonValue> {
|
|
95
|
+
const parsed = jsonObject(value ?? {}, path);
|
|
96
|
+
for (const [key, expression] of Object.entries(parsed)) {
|
|
97
|
+
if (!identifierPattern.test(key)) throw new ApiError(400, "invalid_workflow", `${path}.${key} has an invalid mapped field name.`, { path: `${path}.${key}` });
|
|
98
|
+
if (typeof expression === "string" && prohibitedExpressionPattern.test(expression)) {
|
|
99
|
+
throw new ApiError(400, "unsafe_workflow_expression", `${path}.${key} contains a prohibited expression.`, { path: `${path}.${key}` });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseNode(value: unknown, index: number): WorkflowNodeV1 {
|
|
106
|
+
const path = `nodes[${index}]`;
|
|
107
|
+
const input = object(value, path);
|
|
108
|
+
knownFields(input, [
|
|
109
|
+
"id", "kind", "name", "description", "input_mapping", "timeout_policy",
|
|
110
|
+
"retry_policy", "error_port", "config", "metadata",
|
|
111
|
+
], path);
|
|
112
|
+
if (typeof input.kind !== "string" || !nodeKinds.has(input.kind as WorkflowNodeKind)) {
|
|
113
|
+
throw new ApiError(400, "invalid_workflow", `${path}.kind is unsupported.`, { path: `${path}.kind` });
|
|
114
|
+
}
|
|
115
|
+
const metadata = input.metadata === undefined ? undefined : object(input.metadata, `${path}.metadata`);
|
|
116
|
+
if (metadata) knownFields(metadata, ["x", "y", "group_id"], `${path}.metadata`);
|
|
117
|
+
const numberOrUndefined = (candidate: unknown, childPath: string): number | undefined => {
|
|
118
|
+
if (candidate === undefined) return undefined;
|
|
119
|
+
if (typeof candidate !== "number" || !Number.isFinite(candidate) || Math.abs(candidate) > 1_000_000) {
|
|
120
|
+
throw new ApiError(400, "invalid_workflow", `${childPath} must be a bounded number.`, { path: childPath });
|
|
121
|
+
}
|
|
122
|
+
return candidate;
|
|
123
|
+
};
|
|
124
|
+
const groupId = metadata?.group_id === undefined ? undefined : identifier(metadata.group_id, `${path}.metadata.group_id`);
|
|
125
|
+
return {
|
|
126
|
+
id: identifier(input.id, `${path}.id`),
|
|
127
|
+
kind: input.kind as WorkflowNodeKind,
|
|
128
|
+
name: text(input.name, `${path}.name`, 1, 160),
|
|
129
|
+
...(input.description === undefined ? {} : { description: text(input.description, `${path}.description`, 1, 2_000) }),
|
|
130
|
+
inputMapping: parseMapping(input.input_mapping, `${path}.input_mapping`),
|
|
131
|
+
...(input.timeout_policy === undefined ? {} : { timeoutPolicy: jsonObject(input.timeout_policy, `${path}.timeout_policy`) }),
|
|
132
|
+
...(input.retry_policy === undefined ? {} : { retryPolicy: jsonObject(input.retry_policy, `${path}.retry_policy`) }),
|
|
133
|
+
...(input.error_port === undefined ? {} : { errorPort: identifier(input.error_port, `${path}.error_port`) }),
|
|
134
|
+
config: jsonObject(input.config ?? {}, `${path}.config`),
|
|
135
|
+
...(metadata ? { metadata: {
|
|
136
|
+
...(metadata.x === undefined ? {} : { x: numberOrUndefined(metadata.x, `${path}.metadata.x`) }),
|
|
137
|
+
...(metadata.y === undefined ? {} : { y: numberOrUndefined(metadata.y, `${path}.metadata.y`) }),
|
|
138
|
+
...(groupId === undefined ? {} : { groupId }),
|
|
139
|
+
} } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseEndpoint(value: unknown, path: string): { nodeId: string; port: string } {
|
|
144
|
+
const input = object(value, path);
|
|
145
|
+
knownFields(input, ["node_id", "port"], path);
|
|
146
|
+
return { nodeId: identifier(input.node_id, `${path}.node_id`), port: identifier(input.port, `${path}.port`) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseEdge(value: unknown, index: number): WorkflowEdgeV1 {
|
|
150
|
+
const path = `edges[${index}]`;
|
|
151
|
+
const input = object(value, path);
|
|
152
|
+
knownFields(input, ["id", "from", "to", "label"], path);
|
|
153
|
+
return {
|
|
154
|
+
id: identifier(input.id, `${path}.id`),
|
|
155
|
+
from: parseEndpoint(input.from, `${path}.from`),
|
|
156
|
+
to: parseEndpoint(input.to, `${path}.to`),
|
|
157
|
+
...(input.label === undefined ? {} : { label: optionalText(input.label, `${path}.label`, 160) }),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function parseWorkflowGraph(value: unknown): WorkflowGraphV1 {
|
|
162
|
+
const input = object(value, "workflow");
|
|
163
|
+
knownFields(input, [
|
|
164
|
+
"schema_version", "purpose", "entry_node_id", "nodes", "edges", "input_schema",
|
|
165
|
+
"terminal_codes", "default_policies",
|
|
166
|
+
], "workflow");
|
|
167
|
+
if (input.schema_version !== "1") throw new ApiError(400, "unsupported_workflow_schema", "workflow.schema_version must be 1.", { path: "workflow.schema_version" });
|
|
168
|
+
if (typeof input.purpose !== "string" || !purposes.has(input.purpose as WorkflowGraphV1["purpose"])) {
|
|
169
|
+
throw new ApiError(400, "invalid_workflow", "workflow.purpose is unsupported.", { path: "workflow.purpose" });
|
|
170
|
+
}
|
|
171
|
+
if (!Array.isArray(input.nodes) || input.nodes.length < 2 || input.nodes.length > 200) {
|
|
172
|
+
throw new ApiError(400, "invalid_workflow", "workflow.nodes must contain 2 to 200 nodes.", { path: "workflow.nodes" });
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(input.edges) || input.edges.length < 1 || input.edges.length > 1_000) {
|
|
175
|
+
throw new ApiError(400, "invalid_workflow", "workflow.edges must contain 1 to 1,000 edges.", { path: "workflow.edges" });
|
|
176
|
+
}
|
|
177
|
+
if (!Array.isArray(input.terminal_codes) || input.terminal_codes.length < 1 || input.terminal_codes.length > 100) {
|
|
178
|
+
throw new ApiError(400, "invalid_workflow", "workflow.terminal_codes must contain 1 to 100 codes.", { path: "workflow.terminal_codes" });
|
|
179
|
+
}
|
|
180
|
+
const terminalCodes = input.terminal_codes.map((value, index) => identifier(value, `workflow.terminal_codes[${index}]`));
|
|
181
|
+
if (new Set(terminalCodes).size !== terminalCodes.length) throw new ApiError(400, "invalid_workflow", "workflow.terminal_codes contains duplicates.");
|
|
182
|
+
const defaults = object(input.default_policies ?? {}, "workflow.default_policies");
|
|
183
|
+
knownFields(defaults, ["timeout", "retry", "data_retention_class"], "workflow.default_policies");
|
|
184
|
+
return {
|
|
185
|
+
schemaVersion: "1",
|
|
186
|
+
purpose: input.purpose as WorkflowGraphV1["purpose"],
|
|
187
|
+
entryNodeId: identifier(input.entry_node_id, "workflow.entry_node_id"),
|
|
188
|
+
nodes: input.nodes.map(parseNode),
|
|
189
|
+
edges: input.edges.map(parseEdge),
|
|
190
|
+
inputSchema: jsonObject(input.input_schema ?? { type: "object" }, "workflow.input_schema"),
|
|
191
|
+
terminalCodes,
|
|
192
|
+
defaultPolicies: {
|
|
193
|
+
...(defaults.timeout === undefined ? {} : { timeout: jsonValue(defaults.timeout, "workflow.default_policies.timeout") }),
|
|
194
|
+
...(defaults.retry === undefined ? {} : { retry: jsonObject(defaults.retry, "workflow.default_policies.retry") }),
|
|
195
|
+
...(defaults.data_retention_class === undefined ? {} : {
|
|
196
|
+
dataRetentionClass: identifier(defaults.data_retention_class, "workflow.default_policies.data_retention_class"),
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function configString(node: WorkflowNodeV1, key: string): string | null {
|
|
203
|
+
const value = node.config[key];
|
|
204
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function configStringList(node: WorkflowNodeV1, key: string): string[] {
|
|
208
|
+
const value = node.config[key];
|
|
209
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function issue(code: string, message: string, nodeId?: string, edgeId?: string): WorkflowValidationIssue {
|
|
213
|
+
return { code, message, ...(nodeId ? { nodeId } : {}), ...(edgeId ? { edgeId } : {}) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function dependencyReferences(nodes: WorkflowNodeV1[]): WorkflowDependencyReference[] {
|
|
217
|
+
const references = new Map<string, WorkflowDependencyReference>();
|
|
218
|
+
const add = (type: WorkflowDependencyReference["type"], versionId: string | null, nodeId: string) => {
|
|
219
|
+
if (!versionId || !versionReferencePattern.test(versionId)) return;
|
|
220
|
+
const dependency = { type, key: `${type}:${nodeId}`, versionId };
|
|
221
|
+
references.set(`${type}:${versionId}`, dependency);
|
|
222
|
+
};
|
|
223
|
+
for (const node of nodes) {
|
|
224
|
+
add("component", configString(node, "component_version_id"), node.id);
|
|
225
|
+
add("connection", configString(node, "connection_version_id"), node.id);
|
|
226
|
+
add("policy", configString(node, "policy_version_id"), node.id);
|
|
227
|
+
add("template", configString(node, "template_version_id"), node.id);
|
|
228
|
+
add("model_alias", configString(node, "model_alias_version_id"), node.id);
|
|
229
|
+
add("agent", configString(node, "agent_version_id"), node.id);
|
|
230
|
+
add("workflow", configString(node, "workflow_version_id"), node.id);
|
|
231
|
+
}
|
|
232
|
+
return [...references.values()].sort((left, right) => `${left.type}:${left.versionId}`.localeCompare(`${right.type}:${right.versionId}`));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function capabilityManifest(graph: WorkflowGraphV1, estimatedMaximumSteps: number): WorkflowCapabilityManifest {
|
|
236
|
+
const capabilities = new Set<string>();
|
|
237
|
+
const components = new Set<string>();
|
|
238
|
+
const connections = new Set<string>();
|
|
239
|
+
const eventTypes = new Set<string>();
|
|
240
|
+
const audiences = new Set<string>();
|
|
241
|
+
const actionCodes = new Set<string>();
|
|
242
|
+
let maximumParallelBranches = 1;
|
|
243
|
+
for (const node of graph.nodes) {
|
|
244
|
+
const capabilityByKind: Partial<Record<WorkflowNodeKind, string>> = {
|
|
245
|
+
enrichment: "component.execute", ai_proposal: "agent.execute", human_task: "task.create",
|
|
246
|
+
send_message: "message.send", wait_for_event: "event.wait", decision: "decision.record",
|
|
247
|
+
action: "action.propose", handoff: "queue.handoff", start_linked_workflow: "workflow.linked.start",
|
|
248
|
+
};
|
|
249
|
+
const capability = capabilityByKind[node.kind];
|
|
250
|
+
if (capability) capabilities.add(capability);
|
|
251
|
+
const component = configString(node, "component_version_id");
|
|
252
|
+
const connection = configString(node, "connection_version_id");
|
|
253
|
+
const eventType = configString(node, "event_type");
|
|
254
|
+
const audience = configString(node, "audience");
|
|
255
|
+
const actionCode = configString(node, "action_code");
|
|
256
|
+
if (component) components.add(component);
|
|
257
|
+
if (connection) connections.add(connection);
|
|
258
|
+
if (eventType) eventTypes.add(eventType);
|
|
259
|
+
if (audience) audiences.add(audience);
|
|
260
|
+
if (actionCode) actionCodes.add(actionCode);
|
|
261
|
+
if (node.kind === "parallel_group") maximumParallelBranches = Math.max(maximumParallelBranches, configStringList(node, "branch_ports").length);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
version: 1,
|
|
265
|
+
capabilities: [...capabilities].sort(),
|
|
266
|
+
components: [...components].sort(),
|
|
267
|
+
connections: [...connections].sort(),
|
|
268
|
+
eventTypes: [...eventTypes].sort(),
|
|
269
|
+
audiences: [...audiences].sort(),
|
|
270
|
+
actionCodes: [...actionCodes].sort(),
|
|
271
|
+
maximumParallelBranches,
|
|
272
|
+
maximumStepExecutions: estimatedMaximumSteps,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function validateWorkflowGraph(graph: WorkflowGraphV1, authorityMode: WorkflowAuthorityMode = "assist"): WorkflowValidationResult {
|
|
277
|
+
const errors: WorkflowValidationIssue[] = [];
|
|
278
|
+
const warnings: WorkflowValidationIssue[] = [];
|
|
279
|
+
const nodeById = new Map<string, WorkflowNodeV1>();
|
|
280
|
+
const edgeIds = new Set<string>();
|
|
281
|
+
const outgoing = new Map<string, WorkflowEdgeV1[]>();
|
|
282
|
+
const incoming = new Map<string, WorkflowEdgeV1[]>();
|
|
283
|
+
for (const node of graph.nodes) {
|
|
284
|
+
if (nodeById.has(node.id)) errors.push(issue("duplicate_node_id", `Node ID ${node.id} is duplicated.`, node.id));
|
|
285
|
+
else nodeById.set(node.id, node);
|
|
286
|
+
outgoing.set(node.id, []);
|
|
287
|
+
incoming.set(node.id, []);
|
|
288
|
+
}
|
|
289
|
+
for (const edge of graph.edges) {
|
|
290
|
+
if (edgeIds.has(edge.id)) errors.push(issue("duplicate_edge_id", `Edge ID ${edge.id} is duplicated.`, undefined, edge.id));
|
|
291
|
+
edgeIds.add(edge.id);
|
|
292
|
+
if (!nodeById.has(edge.from.nodeId) || !nodeById.has(edge.to.nodeId)) {
|
|
293
|
+
errors.push(issue("dangling_edge", `Edge ${edge.id} references a missing node.`, undefined, edge.id));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (edge.from.nodeId === edge.to.nodeId) errors.push(issue("self_cycle", `Edge ${edge.id} creates a self-cycle.`, edge.from.nodeId, edge.id));
|
|
297
|
+
outgoing.get(edge.from.nodeId)!.push(edge);
|
|
298
|
+
incoming.get(edge.to.nodeId)!.push(edge);
|
|
299
|
+
}
|
|
300
|
+
const startNodes = graph.nodes.filter((node) => node.kind === "start");
|
|
301
|
+
const endNodes = graph.nodes.filter((node) => node.kind === "end");
|
|
302
|
+
if (startNodes.length !== 1) errors.push(issue("unique_start_required", "A workflow must contain exactly one start node."));
|
|
303
|
+
if (startNodes[0]?.id !== graph.entryNodeId) errors.push(issue("entry_must_be_start", "entry_node_id must reference the unique start node.", graph.entryNodeId));
|
|
304
|
+
if (!endNodes.length) errors.push(issue("terminal_required", "A workflow must contain at least one end node."));
|
|
305
|
+
if (startNodes[0] && (incoming.get(startNodes[0].id)?.length ?? 0) > 0) errors.push(issue("start_has_incoming_edge", "The start node cannot have incoming edges.", startNodes[0].id));
|
|
306
|
+
for (const node of endNodes) {
|
|
307
|
+
if ((outgoing.get(node.id)?.length ?? 0) > 0) errors.push(issue("end_has_outgoing_edge", "An end node cannot have outgoing edges.", node.id));
|
|
308
|
+
const terminalCode = configString(node, "terminal_code");
|
|
309
|
+
if (!terminalCode || !graph.terminalCodes.includes(terminalCode)) errors.push(issue("invalid_terminal_code", "An end node must reference a declared terminal code.", node.id));
|
|
310
|
+
}
|
|
311
|
+
for (const [nodeId, edges] of outgoing) {
|
|
312
|
+
const ports = new Set<string>();
|
|
313
|
+
for (const edge of edges) {
|
|
314
|
+
if (ports.has(edge.from.port)) errors.push(issue("ambiguous_port", `Node ${nodeId} has multiple edges from port ${edge.from.port}.`, nodeId, edge.id));
|
|
315
|
+
ports.add(edge.from.port);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const color = new Map<string, 0 | 1 | 2>();
|
|
320
|
+
const topologicalOrder: string[] = [];
|
|
321
|
+
const visit = (nodeId: string) => {
|
|
322
|
+
const state = color.get(nodeId) ?? 0;
|
|
323
|
+
if (state === 1) {
|
|
324
|
+
errors.push(issue("cycle_detected", `A directed cycle includes node ${nodeId}.`, nodeId));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (state === 2) return;
|
|
328
|
+
color.set(nodeId, 1);
|
|
329
|
+
for (const edge of outgoing.get(nodeId) ?? []) visit(edge.to.nodeId);
|
|
330
|
+
color.set(nodeId, 2);
|
|
331
|
+
topologicalOrder.push(nodeId);
|
|
332
|
+
};
|
|
333
|
+
if (nodeById.has(graph.entryNodeId)) visit(graph.entryNodeId);
|
|
334
|
+
const reachableNodeIds = [...color.entries()].filter(([, state]) => state === 2).map(([nodeId]) => nodeId).sort();
|
|
335
|
+
for (const node of graph.nodes) {
|
|
336
|
+
if (!color.has(node.id)) errors.push(issue("unreachable_node", `Node ${node.id} is unreachable from the start node.`, node.id));
|
|
337
|
+
if (node.kind !== "start" && (incoming.get(node.id)?.length ?? 0) === 0) errors.push(issue("missing_incoming_edge", `Node ${node.id} has no incoming edge.`, node.id));
|
|
338
|
+
if (node.kind !== "end" && (outgoing.get(node.id)?.length ?? 0) === 0) errors.push(issue("missing_outgoing_edge", `Node ${node.id} has no outgoing edge.`, node.id));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const node of graph.nodes) {
|
|
342
|
+
const ports = new Set((outgoing.get(node.id) ?? []).map((edge) => edge.from.port));
|
|
343
|
+
const requirePort = (port: string, code = "required_port_missing") => {
|
|
344
|
+
if (!ports.has(port)) errors.push(issue(code, `Node ${node.id} requires an outgoing ${port} port.`, node.id));
|
|
345
|
+
};
|
|
346
|
+
if (node.kind === "enrichment") {
|
|
347
|
+
if (!configString(node, "component_version_id")) errors.push(issue("component_version_required", "An enrichment node must pin a component version.", node.id));
|
|
348
|
+
requirePort("success");
|
|
349
|
+
requirePort(node.errorPort ?? "error");
|
|
350
|
+
}
|
|
351
|
+
if (node.kind === "parallel_group") {
|
|
352
|
+
const branchPorts = configStringList(node, "branch_ports");
|
|
353
|
+
const joinNodeId = configString(node, "join_node_id");
|
|
354
|
+
if (branchPorts.length < 2 || branchPorts.length > 32 || new Set(branchPorts).size !== branchPorts.length) {
|
|
355
|
+
errors.push(issue("invalid_parallel_branches", "A parallel group must declare 2 to 32 unique branch_ports.", node.id));
|
|
356
|
+
}
|
|
357
|
+
for (const port of branchPorts) requirePort(port, "parallel_branch_port_missing");
|
|
358
|
+
if (!joinNodeId || nodeById.get(joinNodeId)?.kind !== "join") errors.push(issue("parallel_join_required", "A parallel group must reference a matching join node.", node.id));
|
|
359
|
+
}
|
|
360
|
+
if (node.kind === "join") {
|
|
361
|
+
const requiredBranches = configStringList(node, "required_branches");
|
|
362
|
+
if (!requiredBranches.length) errors.push(issue("join_required_branch_missing", "A join must declare at least one required branch.", node.id));
|
|
363
|
+
requirePort("success");
|
|
364
|
+
}
|
|
365
|
+
if (node.kind === "condition") {
|
|
366
|
+
const casePorts = configStringList(node, "case_ports");
|
|
367
|
+
const defaultPort = configString(node, "default_port");
|
|
368
|
+
if (!casePorts.length) errors.push(issue("condition_cases_required", "A condition must declare case_ports.", node.id));
|
|
369
|
+
for (const port of casePorts) requirePort(port, "condition_port_missing");
|
|
370
|
+
if (!defaultPort) errors.push(issue("condition_default_required", "A condition must declare a default_port.", node.id));
|
|
371
|
+
else requirePort(defaultPort, "condition_default_port_missing");
|
|
372
|
+
const cases = node.config.cases;
|
|
373
|
+
const configuredCasePorts = Array.isArray(cases)
|
|
374
|
+
? cases.flatMap((entry) => entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.port === "string" && "when" in entry ? [entry.port] : [])
|
|
375
|
+
: cases && typeof cases === "object" ? Object.keys(cases) : [];
|
|
376
|
+
if (configuredCasePorts.length !== casePorts.length || new Set(configuredCasePorts).size !== configuredCasePorts.length
|
|
377
|
+
|| casePorts.some((port) => !configuredCasePorts.includes(port))) {
|
|
378
|
+
errors.push(issue("condition_case_expression_mismatch", "Every declared condition case port must have exactly one restricted expression.", node.id));
|
|
379
|
+
}
|
|
380
|
+
if (defaultPort && casePorts.includes(defaultPort)) errors.push(issue("condition_default_overlaps_case", "The condition default port must be distinct from its case ports.", node.id));
|
|
381
|
+
}
|
|
382
|
+
if (node.kind === "ai_proposal") {
|
|
383
|
+
if (!configString(node, "agent_version_id") && !configString(node, "component_version_id")) errors.push(issue("ai_version_required", "An AI proposal must pin an agent or component version.", node.id));
|
|
384
|
+
const resultPorts = configStringList(node, "result_ports");
|
|
385
|
+
if (!resultPorts.length) errors.push(issue("ai_result_ports_required", "An AI proposal must declare allowed result_ports.", node.id));
|
|
386
|
+
for (const port of resultPorts) requirePort(port, "ai_result_port_missing");
|
|
387
|
+
requirePort(node.errorPort ?? "error");
|
|
388
|
+
}
|
|
389
|
+
if (node.kind === "human_task") {
|
|
390
|
+
if (!configString(node, "queue_id")) errors.push(issue("human_task_queue_required", "A human task must declare an eligible queue_id.", node.id));
|
|
391
|
+
if (!node.config.response_schema || typeof node.config.response_schema !== "object") errors.push(issue("human_task_schema_required", "A human task must declare response_schema.", node.id));
|
|
392
|
+
requirePort("completed");
|
|
393
|
+
requirePort("timeout");
|
|
394
|
+
}
|
|
395
|
+
if (node.kind === "send_message") {
|
|
396
|
+
const audience = configString(node, "audience");
|
|
397
|
+
if (!audience || !["reporter", "affected_user", "internal"].includes(audience)) errors.push(issue("message_audience_required", "A message node must declare an immutable supported audience.", node.id));
|
|
398
|
+
if (!configString(node, "template_version_id")) errors.push(issue("message_template_required", "A message node must pin a template version.", node.id));
|
|
399
|
+
const deliveryChannels = Array.isArray(node.config.delivery_channels)
|
|
400
|
+
? configStringList(node, "delivery_channels") : audience === "internal" ? [] : ["customer_webhook", "email"];
|
|
401
|
+
if ((audience !== "internal" && !deliveryChannels.length) || deliveryChannels.some((channel) => !["customer_webhook", "email"].includes(channel))
|
|
402
|
+
|| new Set(deliveryChannels).size !== deliveryChannels.length) {
|
|
403
|
+
errors.push(issue("message_delivery_channels_invalid", "Message delivery_channels must be a unique subset of customer_webhook and email.", node.id));
|
|
404
|
+
}
|
|
405
|
+
if (audience === "internal" && deliveryChannels.length) {
|
|
406
|
+
errors.push(issue("internal_message_external_delivery_denied", "Internal messages cannot use external delivery channels.", node.id));
|
|
407
|
+
}
|
|
408
|
+
requirePort("sent");
|
|
409
|
+
requirePort(node.errorPort ?? "error");
|
|
410
|
+
}
|
|
411
|
+
if (node.kind === "wait_for_event") {
|
|
412
|
+
const eventType = configString(node, "event_type");
|
|
413
|
+
const timeoutSeconds = node.config.timeout_seconds;
|
|
414
|
+
if (!eventType || !eventTypePattern.test(eventType)) errors.push(issue("event_type_required", "A wait node must declare a Workflow-compatible event_type.", node.id));
|
|
415
|
+
if (!Number.isInteger(timeoutSeconds) || Number(timeoutSeconds) < 1 || Number(timeoutSeconds) > 31_536_000) errors.push(issue("wait_timeout_required", "A wait node timeout_seconds must be between 1 and 31,536,000.", node.id));
|
|
416
|
+
requirePort("received");
|
|
417
|
+
requirePort("timeout");
|
|
418
|
+
}
|
|
419
|
+
if (node.kind === "delay" || node.kind === "delay_until") {
|
|
420
|
+
if (node.kind === "delay" && (!Number.isInteger(node.config.duration_seconds) || Number(node.config.duration_seconds) < 1)) errors.push(issue("delay_duration_required", "A delay node must declare a positive duration_seconds.", node.id));
|
|
421
|
+
if (node.kind === "delay_until" && !node.inputMapping.timestamp) errors.push(issue("delay_timestamp_required", "A delay_until node must map timestamp.", node.id));
|
|
422
|
+
requirePort("completed");
|
|
423
|
+
}
|
|
424
|
+
if (node.kind === "decision") {
|
|
425
|
+
if (!configString(node, "policy_version_id")) errors.push(issue("decision_policy_required", "A decision node must pin a policy version.", node.id));
|
|
426
|
+
for (const field of ["decision_code", "policy_code", "rationale", "evidence_references", "finding_ids"]) {
|
|
427
|
+
if (!(field in node.inputMapping)) errors.push(issue("decision_mapping_required", `A decision node must map ${field}.`, node.id));
|
|
428
|
+
}
|
|
429
|
+
requirePort("recorded");
|
|
430
|
+
requirePort(node.errorPort ?? "error");
|
|
431
|
+
}
|
|
432
|
+
if (node.kind === "action") {
|
|
433
|
+
if (!configString(node, "component_version_id") || !configString(node, "action_code")) errors.push(issue("action_component_required", "An action node must pin a component version and action_code.", node.id));
|
|
434
|
+
if (!node.config.approval_policy || typeof node.config.approval_policy !== "object") errors.push(issue("action_approval_required", "An action node must declare approval_policy.", node.id));
|
|
435
|
+
if (!node.inputMapping.idempotency_key) errors.push(issue("action_idempotency_required", "An action node must map a stable idempotency_key.", node.id));
|
|
436
|
+
for (const field of ["target", "payload", "preconditions"]) {
|
|
437
|
+
if (!(field in node.inputMapping)) errors.push(issue("action_mapping_required", `An action node must map ${field}.`, node.id));
|
|
438
|
+
}
|
|
439
|
+
requirePort("applied");
|
|
440
|
+
requirePort("rejected");
|
|
441
|
+
requirePort("timeout");
|
|
442
|
+
requirePort(node.errorPort ?? "error");
|
|
443
|
+
if (authorityMode === "shadow") errors.push(issue("shadow_action_prohibited", "A shadow workflow cannot contain an executable action node.", node.id));
|
|
444
|
+
}
|
|
445
|
+
if (node.kind === "handoff") {
|
|
446
|
+
if (!configStringList(node, "allowed_queue_ids").length) errors.push(issue("handoff_allowlist_required", "A handoff must declare allowed_queue_ids.", node.id));
|
|
447
|
+
if (!node.inputMapping.queue_id) errors.push(issue("handoff_queue_mapping_required", "A handoff must map queue_id.", node.id));
|
|
448
|
+
requirePort("handed_off");
|
|
449
|
+
requirePort(node.errorPort ?? "error");
|
|
450
|
+
}
|
|
451
|
+
if (node.kind === "start_linked_workflow") {
|
|
452
|
+
if (!configString(node, "workflow_version_id")) errors.push(issue("linked_workflow_required", "A linked workflow node must pin a workflow version.", node.id));
|
|
453
|
+
requirePort("started");
|
|
454
|
+
requirePort(node.errorPort ?? "error");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const estimatedMaximumSteps = Math.min(1_000_000, graph.nodes.length + graph.edges.length);
|
|
458
|
+
if (estimatedMaximumSteps > 1_000) errors.push(issue("step_budget_exceeded", "The graph can exceed the 1,000-step product limit."));
|
|
459
|
+
if (graph.nodes.length > 150) warnings.push(issue("large_workflow", "This workflow is close to the 200-node publication limit."));
|
|
460
|
+
topologicalOrder.reverse();
|
|
461
|
+
return {
|
|
462
|
+
valid: errors.length === 0,
|
|
463
|
+
errors,
|
|
464
|
+
warnings,
|
|
465
|
+
dependencies: dependencyReferences(graph.nodes),
|
|
466
|
+
capabilityManifest: capabilityManifest(graph, estimatedMaximumSteps),
|
|
467
|
+
reachableNodeIds,
|
|
468
|
+
topologicalOrder,
|
|
469
|
+
estimatedMaximumSteps,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function parseWorkflowDefinition(value: unknown): WorkflowDefinitionInput {
|
|
474
|
+
const input = object(value, "workflow_definition");
|
|
475
|
+
knownFields(input, ["key", "name", "description", "purpose"], "workflow_definition");
|
|
476
|
+
const key = text(input.key, "workflow_definition.key", 1, 100);
|
|
477
|
+
if (!keyPattern.test(key)) throw new ApiError(400, "invalid_workflow_key", "workflow_definition.key must be lower-case kebab-case.");
|
|
478
|
+
if (typeof input.purpose !== "string" || !purposes.has(input.purpose as WorkflowGraphV1["purpose"])) {
|
|
479
|
+
throw new ApiError(400, "invalid_workflow", "workflow_definition.purpose is unsupported.");
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
key,
|
|
483
|
+
name: text(input.name, "workflow_definition.name", 1, 160),
|
|
484
|
+
description: typeof input.description === "string" ? input.description.trim().slice(0, 2_000) : "",
|
|
485
|
+
purpose: input.purpose as WorkflowGraphV1["purpose"],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function parseWorkflowPublish(value: unknown): WorkflowPublishInput {
|
|
490
|
+
const input = object(value, "workflow_publish");
|
|
491
|
+
knownFields(input, ["expected_revision", "authority_mode", "test_evidence"], "workflow_publish");
|
|
492
|
+
if (!Number.isInteger(input.expected_revision) || Number(input.expected_revision) < 1) throw new ApiError(400, "invalid_workflow", "workflow_publish.expected_revision must be a positive integer.");
|
|
493
|
+
if (typeof input.authority_mode !== "string" || !authorityModes.has(input.authority_mode as WorkflowAuthorityMode)) throw new ApiError(400, "invalid_workflow", "workflow_publish.authority_mode is unsupported.");
|
|
494
|
+
return {
|
|
495
|
+
expectedRevision: Number(input.expected_revision),
|
|
496
|
+
authorityMode: input.authority_mode as WorkflowAuthorityMode,
|
|
497
|
+
testEvidence: jsonObject(input.test_evidence ?? {}, "workflow_publish.test_evidence"),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function graphForStorage(graph: WorkflowGraphV1): Record<string, unknown> {
|
|
502
|
+
return {
|
|
503
|
+
schema_version: graph.schemaVersion,
|
|
504
|
+
purpose: graph.purpose,
|
|
505
|
+
entry_node_id: graph.entryNodeId,
|
|
506
|
+
nodes: graph.nodes.map((node) => ({
|
|
507
|
+
id: node.id,
|
|
508
|
+
kind: node.kind,
|
|
509
|
+
name: node.name,
|
|
510
|
+
...(node.description ? { description: node.description } : {}),
|
|
511
|
+
input_mapping: node.inputMapping,
|
|
512
|
+
...(node.timeoutPolicy ? { timeout_policy: node.timeoutPolicy } : {}),
|
|
513
|
+
...(node.retryPolicy ? { retry_policy: node.retryPolicy } : {}),
|
|
514
|
+
...(node.errorPort ? { error_port: node.errorPort } : {}),
|
|
515
|
+
config: node.config,
|
|
516
|
+
...(node.metadata ? { metadata: {
|
|
517
|
+
...(node.metadata.x === undefined ? {} : { x: node.metadata.x }),
|
|
518
|
+
...(node.metadata.y === undefined ? {} : { y: node.metadata.y }),
|
|
519
|
+
...(node.metadata.groupId ? { group_id: node.metadata.groupId } : {}),
|
|
520
|
+
} } : {}),
|
|
521
|
+
})),
|
|
522
|
+
edges: graph.edges.map((edge) => ({
|
|
523
|
+
id: edge.id,
|
|
524
|
+
from: { node_id: edge.from.nodeId, port: edge.from.port },
|
|
525
|
+
to: { node_id: edge.to.nodeId, port: edge.to.port },
|
|
526
|
+
...(edge.label ? { label: edge.label } : {}),
|
|
527
|
+
})),
|
|
528
|
+
input_schema: graph.inputSchema,
|
|
529
|
+
terminal_codes: graph.terminalCodes,
|
|
530
|
+
default_policies: {
|
|
531
|
+
...(graph.defaultPolicies.timeout === undefined ? {} : { timeout: graph.defaultPolicies.timeout }),
|
|
532
|
+
...(graph.defaultPolicies.retry ? { retry: graph.defaultPolicies.retry } : {}),
|
|
533
|
+
...(graph.defaultPolicies.dataRetentionClass ? { data_retention_class: graph.defaultPolicies.dataRetentionClass } : {}),
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function canonicalWorkflowGraph(graph: WorkflowGraphV1): string {
|
|
539
|
+
return canonicalJson(graphForStorage(graph));
|
|
540
|
+
}
|