create-safest-tools 0.6.0 → 0.7.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/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canonicalJson } from "./audit";
|
|
2
2
|
import { ApiError } from "./report-http";
|
|
3
|
+
import { compilePlaybook, parsePlaybook, playbookAuthority, PlaybookInputError } from "../shared/playbooks";
|
|
3
4
|
import type {
|
|
4
5
|
JsonObject,
|
|
5
6
|
JsonValue,
|
|
@@ -162,7 +163,7 @@ export function parseWorkflowGraph(value: unknown): WorkflowGraphV1 {
|
|
|
162
163
|
const input = object(value, "workflow");
|
|
163
164
|
knownFields(input, [
|
|
164
165
|
"schema_version", "purpose", "entry_node_id", "nodes", "edges", "input_schema",
|
|
165
|
-
"terminal_codes", "default_policies",
|
|
166
|
+
"terminal_codes", "default_policies", "playbook",
|
|
166
167
|
], "workflow");
|
|
167
168
|
if (input.schema_version !== "1") throw new ApiError(400, "unsupported_workflow_schema", "workflow.schema_version must be 1.", { path: "workflow.schema_version" });
|
|
168
169
|
if (typeof input.purpose !== "string" || !purposes.has(input.purpose as WorkflowGraphV1["purpose"])) {
|
|
@@ -181,7 +182,16 @@ export function parseWorkflowGraph(value: unknown): WorkflowGraphV1 {
|
|
|
181
182
|
if (new Set(terminalCodes).size !== terminalCodes.length) throw new ApiError(400, "invalid_workflow", "workflow.terminal_codes contains duplicates.");
|
|
182
183
|
const defaults = object(input.default_policies ?? {}, "workflow.default_policies");
|
|
183
184
|
knownFields(defaults, ["timeout", "retry", "data_retention_class"], "workflow.default_policies");
|
|
185
|
+
let playbook: WorkflowGraphV1["playbook"];
|
|
186
|
+
if (input.playbook !== undefined) {
|
|
187
|
+
try { playbook = parsePlaybook(input.playbook); }
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof PlaybookInputError) throw new ApiError(400, "invalid_playbook", error.message, { path: error.path });
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
184
193
|
return {
|
|
194
|
+
...(playbook ? { playbook } : {}),
|
|
185
195
|
schemaVersion: "1",
|
|
186
196
|
purpose: input.purpose as WorkflowGraphV1["purpose"],
|
|
187
197
|
entryNodeId: identifier(input.entry_node_id, "workflow.entry_node_id"),
|
|
@@ -276,6 +286,17 @@ function capabilityManifest(graph: WorkflowGraphV1, estimatedMaximumSteps: numbe
|
|
|
276
286
|
export function validateWorkflowGraph(graph: WorkflowGraphV1, authorityMode: WorkflowAuthorityMode = "assist"): WorkflowValidationResult {
|
|
277
287
|
const errors: WorkflowValidationIssue[] = [];
|
|
278
288
|
const warnings: WorkflowValidationIssue[] = [];
|
|
289
|
+
if (graph.playbook) {
|
|
290
|
+
const guide = graph.playbook;
|
|
291
|
+
if (guide.editing_mode === "guided") {
|
|
292
|
+
const structure = (candidate: WorkflowGraphV1): string => canonicalWorkflowGraph({ ...candidate, nodes: candidate.nodes.map(({ metadata: _metadata, ...node }) => node) });
|
|
293
|
+
if (structure(graph) !== structure(parseWorkflowGraph(compilePlaybook(guide)))) errors.push(issue("playbook_graph_mismatch", "The workflow differs from its guide. Save the matching guide or explicitly switch to advanced workflow editing."));
|
|
294
|
+
if (playbookAuthority(guide) !== authorityMode) errors.push(issue("playbook_authority_mismatch", "Publication authority must match the playbook's selected rollout mode."));
|
|
295
|
+
if (!guide.summary || !guide.policy_text || !guide.policy_code || !guide.outcomes.decision_codes.length) errors.push(issue("playbook_rules_incomplete", "Describe the situation, applicable policy and permitted outcomes before publishing."));
|
|
296
|
+
if (guide.rollout === "automate" && (!guide.investigation.enabled || !guide.outcomes.delegated_decision_codes.length)) errors.push(issue("playbook_automation_incomplete", "Automation requires an investigator and explicitly delegated outcomes."));
|
|
297
|
+
if (guide.rollout !== "automate" && guide.outcomes.actions.some(action => action.approval !== "human")) errors.push(issue("playbook_action_approval_required", "Actions in Assist require human approval."));
|
|
298
|
+
} else warnings.push(issue("playbook_advanced_workflow", "This playbook uses advanced workflow editing. Its guide is reference material; inspect the workflow for executable behaviour."));
|
|
299
|
+
}
|
|
279
300
|
const nodeById = new Map<string, WorkflowNodeV1>();
|
|
280
301
|
const edgeIds = new Set<string>();
|
|
281
302
|
const outgoing = new Map<string, WorkflowEdgeV1[]>();
|
|
@@ -500,6 +521,7 @@ export function parseWorkflowPublish(value: unknown): WorkflowPublishInput {
|
|
|
500
521
|
|
|
501
522
|
export function graphForStorage(graph: WorkflowGraphV1): Record<string, unknown> {
|
|
502
523
|
return {
|
|
524
|
+
...(graph.playbook ? { playbook: graph.playbook } : {}),
|
|
503
525
|
schema_version: graph.schemaVersion,
|
|
504
526
|
purpose: graph.purpose,
|
|
505
527
|
entry_node_id: graph.entryNodeId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noticeTemplateVariables } from "../shared/notice-inputs";
|
|
1
2
|
import { canonicalJson } from "./audit";
|
|
2
3
|
import { sha256 } from "./report-crypto";
|
|
3
4
|
import { ApiError } from "./report-http";
|
|
@@ -20,6 +21,7 @@ import { simulateWorkflow, simulationMatchesExpected } from "./workflow-simulato
|
|
|
20
21
|
import type { JsonObject } from "./workflow-platform-types";
|
|
21
22
|
|
|
22
23
|
interface WorkflowListRow {
|
|
24
|
+
graphJson: string | null;
|
|
23
25
|
id: string;
|
|
24
26
|
key: string;
|
|
25
27
|
name: string;
|
|
@@ -72,6 +74,7 @@ function parsedJson<T>(value: string | null, fallback: T): T {
|
|
|
72
74
|
|
|
73
75
|
function workflowFromRow(row: WorkflowListRow): Record<string, unknown> {
|
|
74
76
|
return {
|
|
77
|
+
playbook: parsedJson<{ playbook?: unknown }>(row.graphJson, {}).playbook ?? null,
|
|
75
78
|
id: row.id,
|
|
76
79
|
key: row.key,
|
|
77
80
|
name: row.name,
|
|
@@ -144,6 +147,7 @@ export async function listWorkflowDefinitions(
|
|
|
144
147
|
SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
|
|
145
148
|
w.active_version_id AS activeVersionId, w.created_by AS createdBy,
|
|
146
149
|
w.created_at AS createdAt, w.updated_at AS updatedAt,
|
|
150
|
+
COALESCE(d.graph_json, v.graph_json) AS graphJson,
|
|
147
151
|
d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
|
|
148
152
|
d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
|
|
149
153
|
v.published_at AS publishedAt,
|
|
@@ -166,6 +170,7 @@ export async function loadWorkflowDefinition(
|
|
|
166
170
|
SELECT w.id, w.workflow_key AS key, w.name, w.description, w.purpose, w.status,
|
|
167
171
|
w.active_version_id AS activeVersionId, w.created_by AS createdBy,
|
|
168
172
|
w.created_at AS createdAt, w.updated_at AS updatedAt,
|
|
173
|
+
COALESCE(d.graph_json, v.graph_json) AS graphJson,
|
|
169
174
|
d.id AS draftId, d.revision AS draftRevision, d.validation_status AS validationStatus,
|
|
170
175
|
d.validation_json AS validationJson, v.version, v.authority_mode AS authorityMode,
|
|
171
176
|
v.published_at AS publishedAt,
|
|
@@ -194,7 +199,7 @@ export async function loadWorkflowDefinition(
|
|
|
194
199
|
WHERE v.workflow_id = ?1 ORDER BY v.version DESC, v.id DESC
|
|
195
200
|
`).bind(workflowId).all<Record<string, unknown>>(),
|
|
196
201
|
db.prepare(`
|
|
197
|
-
SELECT id, name, input_json AS inputJson, expected_json AS expectedJson,
|
|
202
|
+
SELECT id, name, revision, input_json AS inputJson, expected_json AS expectedJson,
|
|
198
203
|
failure_injection_json AS failureInjectionJson, updated_at AS updatedAt
|
|
199
204
|
FROM workflow_fixtures WHERE workflow_id = ?1 ORDER BY name, id
|
|
200
205
|
`).bind(workflowId).all<Record<string, unknown>>(),
|
|
@@ -337,6 +342,13 @@ export async function validateWorkflowDraft(
|
|
|
337
342
|
if (!draft) throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
|
|
338
343
|
const graph = parseWorkflowGraph(parsedJson(draft.graph_json, null));
|
|
339
344
|
const validation = validateWorkflowGraph(graph, authorityMode);
|
|
345
|
+
for (const dependency of validation.dependencies) {
|
|
346
|
+
if (!await dependencyAvailable(db, dependency)) {
|
|
347
|
+
validation.errors.push({ code: "workflow_dependency_unavailable", message: `Select a published ${dependency.type} for ${dependency.key}. Version “${dependency.versionId}” is unavailable.`, path: dependency.key });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
validation.errors.push(...await workflowNoticeInputIssues(db, graph));
|
|
351
|
+
validation.valid = validation.errors.length === 0;
|
|
340
352
|
const validationJson = canonicalJson(validation);
|
|
341
353
|
await db.prepare(`
|
|
342
354
|
UPDATE workflow_drafts SET validation_json = ?2, validation_digest = ?3,
|
|
@@ -350,7 +362,7 @@ export async function validateWorkflowDraft(
|
|
|
350
362
|
|
|
351
363
|
export async function listWorkflowFixtures(db: D1Database, workflowId: string): Promise<Record<string, unknown>[]> {
|
|
352
364
|
const rows = await db.prepare(`
|
|
353
|
-
SELECT id, name, input_json AS inputJson, component_results_json AS componentResultsJson,
|
|
365
|
+
SELECT id, name, revision, input_json AS inputJson, component_results_json AS componentResultsJson,
|
|
354
366
|
human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
|
|
355
367
|
failure_injection_json AS failureInjectionJson, created_by AS createdBy,
|
|
356
368
|
created_at AS createdAt, updated_at AS updatedAt
|
|
@@ -371,6 +383,12 @@ export async function listWorkflowFixtures(db: D1Database, workflowId: string):
|
|
|
371
383
|
}));
|
|
372
384
|
}
|
|
373
385
|
|
|
386
|
+
function normalizedFixtureName(value: string): string {
|
|
387
|
+
const name = value.trim();
|
|
388
|
+
if (name.length < 2 || name.length > 120 || /[\u0000-\u001f\u007f]/u.test(name)) throw new ApiError(400, "workflow_fixture_name_invalid", "Use an example name between 2 and 120 characters, without control characters.");
|
|
389
|
+
return name;
|
|
390
|
+
}
|
|
391
|
+
|
|
374
392
|
export async function createWorkflowFixture(
|
|
375
393
|
db: D1Database,
|
|
376
394
|
workflowId: string,
|
|
@@ -390,7 +408,7 @@ export async function createWorkflowFixture(
|
|
|
390
408
|
if (!await db.prepare(`SELECT id FROM workflow_definitions WHERE id = ?1 AND status != 'archived'`).bind(workflowId).first()) {
|
|
391
409
|
throw new ApiError(404, "workflow_not_found", "The workflow does not exist.");
|
|
392
410
|
}
|
|
393
|
-
|
|
411
|
+
input = { ...input, name: normalizedFixtureName(input.name) };
|
|
394
412
|
if (await db.prepare(`SELECT id FROM workflow_fixtures WHERE workflow_id = ?1 AND name = ?2`).bind(workflowId, input.name).first()) {
|
|
395
413
|
throw new ApiError(409, "workflow_fixture_exists", "A fixture with this name already exists.");
|
|
396
414
|
}
|
|
@@ -432,6 +450,7 @@ export async function updateWorkflowFixture(
|
|
|
432
450
|
workflowId: string,
|
|
433
451
|
fixtureId: string,
|
|
434
452
|
input: {
|
|
453
|
+
expectedRevision: number;
|
|
435
454
|
name: string;
|
|
436
455
|
input: JsonObject;
|
|
437
456
|
componentResults: JsonObject;
|
|
@@ -444,31 +463,36 @@ export async function updateWorkflowFixture(
|
|
|
444
463
|
): Promise<{ fixtureId: string; idempotentReplay: boolean }> {
|
|
445
464
|
const replay = await mutationReplay(db, idempotencyKey, "workflow.fixture.update", session.actor.id);
|
|
446
465
|
if (replay) return { fixtureId: String(replay.fixtureId), idempotentReplay: true };
|
|
447
|
-
|
|
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();
|
|
466
|
+
input = { ...input, name: normalizedFixtureName(input.name) };
|
|
467
|
+
const fixture = await db.prepare(`SELECT id, revision FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1`)
|
|
468
|
+
.bind(fixtureId, workflowId).first<{ id: string; revision: number }>();
|
|
450
469
|
if (!fixture) throw new ApiError(404, "workflow_fixture_not_found", "The workflow fixture does not exist.");
|
|
470
|
+
if (fixture.revision !== input.expectedRevision) throw new ApiError(409, "workflow_fixture_revision_conflict", "This example changed after you opened it. Reload the saved example before editing again.");
|
|
471
|
+
if (await db.prepare(`SELECT id FROM workflow_fixtures WHERE workflow_id = ?1 AND name = ?2 AND id != ?3`).bind(workflowId, input.name, fixtureId).first()) throw new ApiError(409, "workflow_fixture_exists", "An example with this name already exists.");
|
|
451
472
|
const timestamp = now();
|
|
452
473
|
const response = { fixtureId };
|
|
453
|
-
await db.batch([
|
|
474
|
+
try { await db.batch([
|
|
454
475
|
db.prepare(`
|
|
455
476
|
UPDATE workflow_fixtures SET name = ?3, input_json = ?4,
|
|
456
477
|
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
|
|
478
|
+
expected_json = ?7, failure_injection_json = ?8, updated_at = ?9, revision = revision + 1
|
|
479
|
+
WHERE id = ?1 AND workflow_id = ?2 AND revision = ?10
|
|
459
480
|
`).bind(
|
|
460
481
|
fixtureId, workflowId, input.name, canonicalJson(input.input),
|
|
461
482
|
canonicalJson(input.componentResults), canonicalJson(input.humanResponses),
|
|
462
|
-
canonicalJson(input.expected), canonicalJson(input.failureInjection), timestamp,
|
|
483
|
+
canonicalJson(input.expected), canonicalJson(input.failureInjection), timestamp, input.expectedRevision,
|
|
463
484
|
),
|
|
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),
|
|
485
|
+
db.prepare(`INSERT INTO configuration_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at) VALUES (?1, 'workflow.fixture.update', ?2, (SELECT id FROM workflow_fixtures WHERE id = ?3 AND revision = ?6 AND changes() = 1), ?4, ?5)`)
|
|
486
|
+
.bind(idempotencyKey, session.actor.id, fixtureId, canonicalJson(response), timestamp, input.expectedRevision + 1),
|
|
466
487
|
auditStatement(db, {
|
|
467
488
|
idempotencyKey: `workflow-fixture-update:${idempotencyKey}`,
|
|
468
489
|
action: "workflow.fixture_updated", session, targetType: "workflow_fixture",
|
|
469
490
|
targetId: fixtureId, details: { workflowId, name: input.name }, createdAt: timestamp,
|
|
470
491
|
}),
|
|
471
|
-
]);
|
|
492
|
+
]); } catch (error) {
|
|
493
|
+
if (String(error).includes("configuration_mutations.target_id")) throw new ApiError(409, "workflow_fixture_revision_conflict", "This example changed while it was being saved. Reload the saved example before editing again.");
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
472
496
|
return { fixtureId, idempotentReplay: false };
|
|
473
497
|
}
|
|
474
498
|
|
|
@@ -509,12 +533,12 @@ export async function runWorkflowFixtureTest(
|
|
|
509
533
|
db.prepare(`SELECT graph_json AS graphJson, graph_digest AS graphDigest FROM workflow_drafts WHERE workflow_id = ?1 LIMIT 1`)
|
|
510
534
|
.bind(workflowId).first<{ graphJson: string; graphDigest: string }>(),
|
|
511
535
|
db.prepare(`
|
|
512
|
-
SELECT input_json AS inputJson, component_results_json AS componentResultsJson,
|
|
536
|
+
SELECT revision, name, input_json AS inputJson, component_results_json AS componentResultsJson,
|
|
513
537
|
human_responses_json AS humanResponsesJson, expected_json AS expectedJson,
|
|
514
538
|
failure_injection_json AS failureInjectionJson
|
|
515
539
|
FROM workflow_fixtures WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
|
|
516
540
|
`).bind(fixtureId, workflowId).first<{
|
|
517
|
-
inputJson: string; componentResultsJson: string; humanResponsesJson: string;
|
|
541
|
+
revision: number; name: string; inputJson: string; componentResultsJson: string; humanResponsesJson: string;
|
|
518
542
|
expectedJson: string; failureInjectionJson: string;
|
|
519
543
|
}>(),
|
|
520
544
|
]);
|
|
@@ -528,7 +552,9 @@ export async function runWorkflowFixtureTest(
|
|
|
528
552
|
failureInjection: parsedJson(fixture.failureInjectionJson, {}),
|
|
529
553
|
});
|
|
530
554
|
const expected = parsedJson<JsonObject>(fixture.expectedJson, {});
|
|
531
|
-
const
|
|
555
|
+
const hasExpectation = !graph.playbook || (typeof expected.terminal_code === "string" && graph.nodes.some(node => node.kind === "end" && node.config.terminal_code === expected.terminal_code));
|
|
556
|
+
const simulationError = simulation.error ?? (!hasExpectation ? { code: "example_expected_outcome_required" } : null);
|
|
557
|
+
const passed = hasExpectation && simulation.status === "passed" && simulationMatchesExpected(simulation, expected);
|
|
532
558
|
const status = passed ? "passed" : "failed";
|
|
533
559
|
const testRunId = crypto.randomUUID();
|
|
534
560
|
const timestamp = now();
|
|
@@ -536,22 +562,45 @@ export async function runWorkflowFixtureTest(
|
|
|
536
562
|
status,
|
|
537
563
|
terminalCode: simulation.terminalCode,
|
|
538
564
|
steps: simulation.steps,
|
|
539
|
-
expectedMatched:
|
|
565
|
+
expectedMatched: hasExpectation && simulationMatchesExpected(simulation, expected),
|
|
566
|
+
expected,
|
|
567
|
+
fixtureSnapshot: {
|
|
568
|
+
name: fixture.name, revision: fixture.revision,
|
|
569
|
+
input: parsedJson(fixture.inputJson, {}),
|
|
570
|
+
component_results: parsedJson(fixture.componentResultsJson, {}),
|
|
571
|
+
human_responses: parsedJson(fixture.humanResponsesJson, {}),
|
|
572
|
+
expected,
|
|
573
|
+
failure_injection: parsedJson(fixture.failureInjectionJson, {}),
|
|
574
|
+
},
|
|
575
|
+
error: simulationError,
|
|
540
576
|
resultDigest: `sha256:${await sha256(simulation.canonicalResult)}`,
|
|
541
577
|
};
|
|
542
578
|
await db.prepare(`
|
|
543
579
|
INSERT INTO workflow_test_runs (
|
|
544
580
|
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)
|
|
581
|
+
result_json, error_json, started_by, started_at, finished_at, fixture_revision
|
|
582
|
+
) VALUES (?1, ?2, ?3, ?4, 'simulation', ?5, ?6, ?7, ?8, ?9, ?9, ?10)
|
|
547
583
|
`).bind(
|
|
548
584
|
testRunId, workflowId, fixtureId, draft.graphDigest, status,
|
|
549
|
-
canonicalJson(result),
|
|
550
|
-
session.actor.id, timestamp,
|
|
585
|
+
canonicalJson(result), simulationError ? canonicalJson(simulationError) : null,
|
|
586
|
+
session.actor.id, timestamp, fixture.revision,
|
|
551
587
|
).run();
|
|
552
588
|
return { testRunId, status, graphDigest: draft.graphDigest, result };
|
|
553
589
|
}
|
|
554
590
|
|
|
591
|
+
export async function listWorkflowTestRuns(db: D1Database, workflowId: string): Promise<Record<string, unknown>[]> {
|
|
592
|
+
const rows = await db.prepare(`
|
|
593
|
+
SELECT t.id AS testRunId, t.fixture_id AS fixtureId, t.fixture_revision AS fixtureRevision,
|
|
594
|
+
t.graph_digest AS graphDigest, t.status, t.finished_at AS finishedAt, t.result_json AS resultJson
|
|
595
|
+
FROM workflow_test_runs t WHERE t.workflow_id = ?1
|
|
596
|
+
AND t.id = (SELECT latest.id FROM workflow_test_runs latest
|
|
597
|
+
WHERE latest.workflow_id = ?1 AND latest.fixture_id = t.fixture_id
|
|
598
|
+
ORDER BY latest.rowid DESC LIMIT 1)
|
|
599
|
+
ORDER BY t.finished_at DESC, t.id DESC
|
|
600
|
+
`).bind(workflowId).all<Record<string, unknown>>();
|
|
601
|
+
return rows.results.map(({ resultJson, ...row }) => ({ ...row, result: parsedJson(String(resultJson ?? ""), {}) }));
|
|
602
|
+
}
|
|
603
|
+
|
|
555
604
|
export async function loadWorkflowTestRun(
|
|
556
605
|
db: D1Database,
|
|
557
606
|
workflowId: string,
|
|
@@ -559,7 +608,7 @@ export async function loadWorkflowTestRun(
|
|
|
559
608
|
): Promise<Record<string, unknown> | null> {
|
|
560
609
|
const row = await db.prepare(`
|
|
561
610
|
SELECT id, workflow_id AS workflowId, workflow_version_id AS workflowVersionId,
|
|
562
|
-
fixture_id AS fixtureId, graph_digest AS graphDigest, mode, status,
|
|
611
|
+
fixture_id AS fixtureId, fixture_revision AS fixtureRevision, graph_digest AS graphDigest, mode, status,
|
|
563
612
|
result_json AS resultJson, error_json AS errorJson, started_by AS startedBy,
|
|
564
613
|
started_at AS startedAt, finished_at AS finishedAt
|
|
565
614
|
FROM workflow_test_runs WHERE id = ?1 AND workflow_id = ?2 LIMIT 1
|
|
@@ -624,6 +673,7 @@ export async function diffWorkflowVersion(
|
|
|
624
673
|
againstId = prior?.id;
|
|
625
674
|
}
|
|
626
675
|
const against = againstId ? await loadWorkflowVersion(db, workflowId, againstId) : null;
|
|
676
|
+
if (againstId && !against) throw new ApiError(404, "workflow_version_not_found", "The comparison version does not belong to this playbook.");
|
|
627
677
|
const targetGraph = target.graph as WorkflowGraphV1;
|
|
628
678
|
const againstGraph = against?.graph as WorkflowGraphV1 | undefined;
|
|
629
679
|
const targetNodes = new Map((targetGraph?.nodes ?? []).map((node) => [node.id, canonicalJson(node)]));
|
|
@@ -637,6 +687,8 @@ export async function diffWorkflowVersion(
|
|
|
637
687
|
againstVersionId: against?.id ?? null,
|
|
638
688
|
graphDigestChanged: target.graphDigest !== against?.graphDigest,
|
|
639
689
|
nodes: { added, removed, changed },
|
|
690
|
+
guide: Object.keys(targetGraph?.playbook ?? {}).filter(key => canonicalJson((targetGraph.playbook as unknown as Record<string, unknown>)[key]) !== canonicalJson((againstGraph?.playbook as unknown as Record<string, unknown> | undefined)?.[key] ?? null)),
|
|
691
|
+
edgesChanged: canonicalJson(targetGraph.edges) !== canonicalJson(againstGraph?.edges ?? []),
|
|
640
692
|
authorityMode: { from: against?.authorityMode ?? null, to: target.authorityMode },
|
|
641
693
|
dependencies: { from: against?.dependencyManifest ?? [], to: target.dependencyManifest ?? [] },
|
|
642
694
|
};
|
|
@@ -705,6 +757,23 @@ async function requireDependencies(db: D1Database, dependencies: WorkflowDepende
|
|
|
705
757
|
if (missing.length) throw new ApiError(409, "workflow_dependency_unavailable", "The workflow references unpublished, paused, retired, missing, or incompatible dependencies.", { dependencies: missing });
|
|
706
758
|
}
|
|
707
759
|
|
|
760
|
+
export async function workflowNoticeInputIssues(db: D1Database, graph: WorkflowGraphV1) {
|
|
761
|
+
const issues: { code: string; message: string; path: string }[] = [];
|
|
762
|
+
for (const node of graph.nodes.filter(node => node.kind === "send_message" && node.config.audience !== "internal")) {
|
|
763
|
+
const template = await db.prepare(`SELECT body_template AS body, audience FROM message_template_versions WHERE id = ?1 AND published_at IS NOT NULL`)
|
|
764
|
+
.bind(String(node.config.template_version_id ?? "")).first<{ body: string; audience: string }>();
|
|
765
|
+
if (!template || template.audience !== node.config.audience) {
|
|
766
|
+
issues.push({ code: "notice_template_audience_mismatch", message: `${node.name}: choose a published template for this audience.`, path: node.id });
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const names = noticeTemplateVariables(template.body);
|
|
770
|
+
const missing = names.filter(name => node.inputMapping[name] === undefined || node.inputMapping[name] === "" || (typeof node.inputMapping[name] === "object" && node.inputMapping[name] !== null && !Array.isArray(node.inputMapping[name]) && (node.inputMapping[name] as JsonObject).$literal === ""));
|
|
771
|
+
const extra = Object.keys(node.inputMapping).filter(name => name !== "body" && !names.includes(name));
|
|
772
|
+
if (missing.length || extra.length) issues.push({ code: "notice_inputs_required", message: `${node.name}: ${missing.length ? `provide notice content for ${missing.join(", ")}.` : `remove unused notice inputs ${extra.join(", ")}.`}`, path: node.id });
|
|
773
|
+
}
|
|
774
|
+
return issues;
|
|
775
|
+
}
|
|
776
|
+
|
|
708
777
|
async function persistArtifact(
|
|
709
778
|
db: D1Database,
|
|
710
779
|
bucket: R2Bucket,
|
|
@@ -783,9 +852,33 @@ export async function publishWorkflowDraft(
|
|
|
783
852
|
if (!testRun || testRun.status !== "passed" || testRun.graphDigest !== graphDigest) {
|
|
784
853
|
throw new ApiError(409, "workflow_tests_required", "A passing server-recorded fixture test for this exact draft is required before publication.");
|
|
785
854
|
}
|
|
855
|
+
const suiteEvidence: string[] = [];
|
|
856
|
+
const exampleRecords: { test_run_id: string; name: string; revision: number }[] = [];
|
|
857
|
+
if (graph.playbook) {
|
|
858
|
+
const suite = await db.prepare(`
|
|
859
|
+
SELECT f.id, f.name, f.revision, t.id AS testRunId, t.status
|
|
860
|
+
FROM workflow_fixtures f LEFT JOIN workflow_test_runs t ON t.id = (
|
|
861
|
+
SELECT latest.id FROM workflow_test_runs latest
|
|
862
|
+
WHERE latest.fixture_id = f.id AND latest.workflow_id = ?1
|
|
863
|
+
AND latest.graph_digest = ?2 AND latest.fixture_revision = f.revision
|
|
864
|
+
ORDER BY latest.rowid DESC LIMIT 1
|
|
865
|
+
) WHERE f.workflow_id = ?1 ORDER BY f.name
|
|
866
|
+
`).bind(workflowId, graphDigest).all<{ id: string; name: string; revision: number; testRunId: string | null; status: string | null }>();
|
|
867
|
+
const missing = suite.results.filter(example => example.status !== "passed" || !example.testRunId);
|
|
868
|
+
if (!suite.results.length || missing.length) throw new ApiError(409, "playbook_examples_required", "Every saved example must pass against this exact playbook draft before publication.", { examples: missing.map(example => ({ id: example.id, name: example.name })) });
|
|
869
|
+
suiteEvidence.push(...suite.results.map(example => example.testRunId!));
|
|
870
|
+
exampleRecords.push(...suite.results.map(example => ({ test_run_id: example.testRunId!, name: example.name, revision: example.revision })));
|
|
871
|
+
}
|
|
786
872
|
const validation = validateWorkflowGraph(graph, input.authorityMode);
|
|
787
873
|
if (!validation.valid) throw new ApiError(400, "workflow_validation_failed", "The workflow draft has publication errors.", { errors: validation.errors, warnings: validation.warnings });
|
|
788
874
|
await requireDependencies(db, validation.dependencies);
|
|
875
|
+
const noticeIssues = await workflowNoticeInputIssues(db, graph);
|
|
876
|
+
if (noticeIssues.length) throw new ApiError(409, "notice_inputs_required", "Review participant notice content before publishing.", { errors: noticeIssues });
|
|
877
|
+
for(const node of graph.nodes.filter(candidate=>candidate.config.investigator===true)) {
|
|
878
|
+
const approved=await db.prepare(`SELECT v.id FROM agent_versions v JOIN model_alias_versions m ON m.id=v.model_alias_version_id JOIN model_aliases a ON a.id=m.alias_id JOIN prompt_versions p ON p.id=v.prompt_version_id
|
|
879
|
+
WHERE v.id=?1 AND m.provider='workers_ai' AND m.model LIKE '@cf/%' AND m.retired_at IS NULL AND a.status='active' AND p.status='published'`).bind(String(node.config.agent_version_id)).first();
|
|
880
|
+
if(!approved)throw new ApiError(409,"investigation_model_adapter_unavailable","Choose an active investigator with a published prompt and an approved Workers AI model. Other providers need an explicit investigator adapter.");
|
|
881
|
+
}
|
|
789
882
|
const compiled = await compileWorkflowGraph(graph, input.authorityMode, compatibilityDate);
|
|
790
883
|
const artifactId = await persistArtifact(db, bucket, compiled);
|
|
791
884
|
const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM workflow_versions WHERE workflow_id = ?1`)
|
|
@@ -799,13 +892,13 @@ export async function publishWorkflowDraft(
|
|
|
799
892
|
INSERT INTO workflow_versions (
|
|
800
893
|
id, workflow_id, version, graph_json, graph_digest, input_schema_json,
|
|
801
894
|
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)
|
|
895
|
+
artifact_id, compiler_version, test_evidence_json, published_by, published_at, source_draft_revision
|
|
896
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, '{"type":"object"}', ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
|
804
897
|
`).bind(
|
|
805
898
|
versionId, workflowId, version, canonicalWorkflowGraph(graph), compiled.graphDigest,
|
|
806
899
|
canonicalJson(graph.inputSchema), input.authorityMode, canonicalJson(compiled.dependencyManifest),
|
|
807
900
|
canonicalJson(compiled.capabilityManifest), artifactId, compiled.compilerVersion,
|
|
808
|
-
canonicalJson({ test_run_id: testRun.id, passed: true, graph_digest: testRun.graphDigest }), session.actor.id, publishedAt,
|
|
901
|
+
canonicalJson({ test_run_id: testRun.id, ...(suiteEvidence.length ? { example_test_run_ids: suiteEvidence, examples: exampleRecords } : {}), passed: true, graph_digest: testRun.graphDigest }), session.actor.id, publishedAt, workflow.revision,
|
|
809
902
|
),
|
|
810
903
|
db.prepare(`UPDATE workflow_definitions SET active_version_id = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'active'`)
|
|
811
904
|
.bind(workflowId, versionId, publishedAt),
|
|
@@ -834,7 +927,12 @@ export async function publishWorkflowDraft(
|
|
|
834
927
|
) VALUES (?1, ?2, ?3, ?4)
|
|
835
928
|
`).bind(versionId, dependency.type, dependency.key, dependency.versionId));
|
|
836
929
|
}
|
|
837
|
-
await db.batch(statements);
|
|
930
|
+
try { await db.batch(statements); } catch (error) {
|
|
931
|
+
if (String(error).includes("workflow_publication_conflict")) throw new ApiError(409, "workflow_revision_conflict", "The draft changed during publication. Reload it and test the latest changes.");
|
|
932
|
+
if (String(error).includes("playbook_publication_evidence_changed")) throw new ApiError(409, "playbook_examples_required", "The saved examples or their results changed during publication. Run the examples again.");
|
|
933
|
+
if (String(error).includes("workflow_versions.workflow_id, workflow_versions.version")) throw new ApiError(409, "workflow_publication_conflict", "Another author published a version at the same time. Reload the version history.");
|
|
934
|
+
throw error;
|
|
935
|
+
}
|
|
838
936
|
return { ...response, idempotentReplay: false };
|
|
839
937
|
}
|
|
840
938
|
|
|
@@ -859,7 +957,7 @@ export async function loadQueueWorkflowPins(db: D1Database, queueId: string): Pr
|
|
|
859
957
|
export async function pinQueueWorkflow(
|
|
860
958
|
db: D1Database,
|
|
861
959
|
queueId: string,
|
|
862
|
-
input: { workflowVersionId: string; mode: "shadow" | "primary"; rollbackOfPinId?: string },
|
|
960
|
+
input: { workflowVersionId: string; mode: "shadow" | "primary"; rollbackOfPinId?: string; expectedPinId?: string | null },
|
|
863
961
|
session: OperatorSession,
|
|
864
962
|
idempotencyKey: string,
|
|
865
963
|
): Promise<{ pinId: string; workflowVersionId: string; mode: string; idempotentReplay: boolean }> {
|
|
@@ -885,6 +983,8 @@ export async function pinQueueWorkflow(
|
|
|
885
983
|
const current = await db.prepare(`
|
|
886
984
|
SELECT id FROM queue_workflow_pins WHERE queue_id = ?1 AND mode = ?2 AND deactivated_at IS NULL LIMIT 1
|
|
887
985
|
`).bind(queueId, input.mode).first<{ id: string }>();
|
|
986
|
+
if (input.expectedPinId !== undefined && input.expectedPinId !== (current?.id ?? null)) throw new ApiError(409, "workflow_pin_conflict", "This queue activation changed. Reload the active version before trying again.");
|
|
987
|
+
if (input.rollbackOfPinId && input.rollbackOfPinId !== current?.id) throw new ApiError(409, "workflow_pin_conflict", "The activation being replaced is no longer current.");
|
|
888
988
|
const pinId = crypto.randomUUID();
|
|
889
989
|
const activatedAt = now();
|
|
890
990
|
const response = { pinId, workflowVersionId: input.workflowVersionId, mode: input.mode };
|
|
@@ -911,6 +1011,9 @@ export async function pinQueueWorkflow(
|
|
|
911
1011
|
createdAt: activatedAt,
|
|
912
1012
|
}),
|
|
913
1013
|
);
|
|
914
|
-
await db.batch(statements);
|
|
1014
|
+
try { await db.batch(statements); } catch (error) {
|
|
1015
|
+
if (String(error).includes("queue_workflow_pins.queue_id, queue_workflow_pins.mode")) throw new ApiError(409, "workflow_pin_conflict", "This queue activation changed while saving. Reload the active version before trying again.");
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
915
1018
|
return { ...response, idempotentReplay: false };
|
|
916
1019
|
}
|
|
@@ -3,6 +3,8 @@ import { sha256 } from "./report-crypto";
|
|
|
3
3
|
import { ApiError } from "./report-http";
|
|
4
4
|
import type { OperatorSession, WorkflowDispatchJob } from "./report-types";
|
|
5
5
|
import { startDynamicWorkflow } from "./workflow-dynamic-runtime";
|
|
6
|
+
import { canRequeueUnavailableWorkflowDispatch } from "./workflow-loader-code";
|
|
7
|
+
import { workflowRunEventType } from "./workflow-event-transport";
|
|
6
8
|
import type { JsonObject } from "./workflow-platform-types";
|
|
7
9
|
|
|
8
10
|
interface MutationRow {
|
|
@@ -392,7 +394,7 @@ export async function processWorkflowDispatchJob(
|
|
|
392
394
|
if (dispatch.operation === "event") {
|
|
393
395
|
if (!dispatch.instanceId || !dispatch.eventType || !dispatch.eventPayloadJson) throw new Error("workflow_event_dispatch_not_ready");
|
|
394
396
|
const instance = await env.WORKFLOWS.get(dispatch.instanceId);
|
|
395
|
-
await instance.sendEvent({ type: dispatch.eventType, payload: parsedJson(dispatch.eventPayloadJson, {}) });
|
|
397
|
+
await instance.sendEvent({ type: await workflowRunEventType(env.DB, dispatch.runId, dispatch.eventType), payload: parsedJson(dispatch.eventPayloadJson, {}) });
|
|
396
398
|
const deliveredAt = now();
|
|
397
399
|
await env.DB.batch([
|
|
398
400
|
env.DB.prepare(`
|
|
@@ -428,13 +430,21 @@ export async function processWorkflowDispatchJob(
|
|
|
428
430
|
} catch (error) {
|
|
429
431
|
const delaySeconds = Math.min(900, 5 * (2 ** Math.min(dispatch.attemptCount, 7)));
|
|
430
432
|
const timestamp = now();
|
|
433
|
+
const errorMessage = error instanceof Error ? error.message.slice(0, 200) : "workflow_dispatch_failed";
|
|
434
|
+
console.error(JSON.stringify({
|
|
435
|
+
message: "workflow dispatch attempt failed",
|
|
436
|
+
runId: dispatch.runId,
|
|
437
|
+
outboxId: dispatch.outboxId,
|
|
438
|
+
attemptCount: dispatch.attemptCount,
|
|
439
|
+
error: errorMessage,
|
|
440
|
+
}));
|
|
431
441
|
await env.DB.prepare(`
|
|
432
442
|
UPDATE workflow_dispatch_outbox SET state = CASE WHEN attempt_count >= 8 THEN 'dead_letter' ELSE 'failed' END,
|
|
433
443
|
lease_expires_at = NULL, available_at = ?2, last_error_code = ?3, updated_at = ?4
|
|
434
444
|
WHERE id = ?1 AND state = 'leased'
|
|
435
445
|
`).bind(
|
|
436
446
|
dispatch.outboxId, new Date(Date.now() + delaySeconds * 1_000).toISOString(),
|
|
437
|
-
|
|
447
|
+
errorMessage, timestamp,
|
|
438
448
|
).run();
|
|
439
449
|
if (dispatch.attemptCount >= 8) {
|
|
440
450
|
await env.DB.prepare(`
|
|
@@ -450,7 +460,7 @@ export async function processWorkflowDispatchJob(
|
|
|
450
460
|
|
|
451
461
|
export async function listWorkflowRuns(
|
|
452
462
|
db: D1Database,
|
|
453
|
-
input: { reportId?: string; state?: string; allowedQueueIds?: string[] | null; limit: number },
|
|
463
|
+
input: { reportId?: string; workflowId?: string; state?: string; allowedQueueIds?: string[] | null; limit: number },
|
|
454
464
|
): Promise<Record<string, unknown>[]> {
|
|
455
465
|
const rows = await db.prepare(`
|
|
456
466
|
SELECT r.id, r.report_id AS reportId, r.appeal_id AS appealId,
|
|
@@ -458,9 +468,12 @@ export async function listWorkflowRuns(
|
|
|
458
468
|
r.queue_id AS queueId, r.run_kind AS runKind, r.authority_mode AS authorityMode,
|
|
459
469
|
r.state, r.cloudflare_instance_id AS cloudflareInstanceId,
|
|
460
470
|
r.current_node_id AS currentNodeId, r.error_code AS errorCode,
|
|
471
|
+
(SELECT json_extract(step.value, '$.name') FROM json_each(v.graph_json, '$.nodes') step
|
|
472
|
+
WHERE json_extract(step.value, '$.id') = r.current_node_id LIMIT 1) AS currentNodeName,
|
|
461
473
|
r.started_at AS startedAt, r.waiting_at AS waitingAt,
|
|
462
474
|
r.finished_at AS finishedAt, r.created_at AS createdAt, r.updated_at AS updatedAt,
|
|
463
475
|
w.workflow_key AS workflowKey, w.name AS workflowName, v.version,
|
|
476
|
+
(SELECT public_reference FROM reports WHERE id = r.report_id) AS publicReference,
|
|
464
477
|
(SELECT COUNT(*) FROM workflow_step_runs s WHERE s.run_id = r.id) AS stepCount,
|
|
465
478
|
(SELECT COUNT(*) FROM human_tasks t WHERE t.run_id = r.id AND t.state IN ('open','claimed')) AS openTaskCount
|
|
466
479
|
FROM workflow_runs r
|
|
@@ -468,11 +481,12 @@ export async function listWorkflowRuns(
|
|
|
468
481
|
JOIN workflow_definitions w ON w.id = v.workflow_id
|
|
469
482
|
WHERE (?1 IS NULL OR r.report_id = ?1) AND (?2 IS NULL OR r.state = ?2)
|
|
470
483
|
AND (?3 = 1 OR r.queue_id IN (SELECT value FROM json_each(?4)))
|
|
484
|
+
AND (?6 IS NULL OR w.id = ?6)
|
|
471
485
|
ORDER BY r.created_at DESC, r.id DESC LIMIT ?5
|
|
472
486
|
`).bind(
|
|
473
487
|
input.reportId ?? null, input.state ?? null,
|
|
474
488
|
input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
|
|
475
|
-
canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)),
|
|
489
|
+
canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)), input.workflowId ?? null,
|
|
476
490
|
).all<Record<string, unknown>>();
|
|
477
491
|
return rows.results;
|
|
478
492
|
}
|
|
@@ -535,7 +549,7 @@ export async function sendWorkflowEvent(
|
|
|
535
549
|
if (!run.cloudflare_instance_id) return { eventId, idempotentReplay: false, delivered: false };
|
|
536
550
|
try {
|
|
537
551
|
const instance = await env.WORKFLOWS.get(run.cloudflare_instance_id);
|
|
538
|
-
await instance.sendEvent({ type: input.eventType, payload: input.payload });
|
|
552
|
+
await instance.sendEvent({ type: await workflowRunEventType(env.DB, runId, input.eventType), payload: input.payload });
|
|
539
553
|
const deliveredAt = now();
|
|
540
554
|
await env.DB.batch([
|
|
541
555
|
env.DB.prepare(`UPDATE workflow_event_inbox SET delivered_at = ?2, delivery_attempts = delivery_attempts + 1 WHERE id = ?1`).bind(eventId, deliveredAt),
|
|
@@ -592,8 +606,8 @@ export async function reconcileWorkflowRun(
|
|
|
592
606
|
idempotentReplay: true,
|
|
593
607
|
};
|
|
594
608
|
}
|
|
595
|
-
const run = await env.DB.prepare(`SELECT id, state, cloudflare_instance_id AS instanceId FROM workflow_runs WHERE id = ?1 LIMIT 1`)
|
|
596
|
-
.bind(runId).first<{ id: string; state: string; instanceId: string | null }>();
|
|
609
|
+
const run = await env.DB.prepare(`SELECT id, state, cloudflare_instance_id AS instanceId, error_code AS errorCode FROM workflow_runs WHERE id = ?1 LIMIT 1`)
|
|
610
|
+
.bind(runId).first<{ id: string; state: string; instanceId: string | null; errorCode: string | null }>();
|
|
597
611
|
if (!run) throw new ApiError(404, "workflow_run_not_found", "The workflow run does not exist.");
|
|
598
612
|
const timestamp = now();
|
|
599
613
|
let state = run.state;
|
|
@@ -618,14 +632,48 @@ export async function reconcileWorkflowRun(
|
|
|
618
632
|
}
|
|
619
633
|
}
|
|
620
634
|
} else {
|
|
621
|
-
let status: InstanceStatus;
|
|
635
|
+
let status: InstanceStatus | null = null;
|
|
622
636
|
try {
|
|
623
637
|
status = await (await env.WORKFLOWS.get(run.instanceId)).status();
|
|
624
638
|
} catch {
|
|
625
|
-
|
|
639
|
+
const dispatchFailure = run.state === "repair_required"
|
|
640
|
+
? await env.DB.prepare(`
|
|
641
|
+
SELECT o.id AS outboxId,
|
|
642
|
+
(SELECT COUNT(*) FROM workflow_step_runs s WHERE s.run_id = ?1) AS stepCount
|
|
643
|
+
FROM workflow_dispatch_outbox o
|
|
644
|
+
WHERE o.run_id = ?1 AND o.operation = 'start'
|
|
645
|
+
AND o.state IN ('failed','dead_letter','leased')
|
|
646
|
+
ORDER BY o.updated_at DESC LIMIT 1
|
|
647
|
+
`).bind(runId).first<{ outboxId: string; stepCount: number }>()
|
|
648
|
+
: null;
|
|
649
|
+
if (!dispatchFailure || !canRequeueUnavailableWorkflowDispatch({
|
|
650
|
+
state: run.state,
|
|
651
|
+
errorCode: run.errorCode,
|
|
652
|
+
stepCount: dispatchFailure.stepCount,
|
|
653
|
+
})) {
|
|
654
|
+
throw new ApiError(503, "workflow_platform_status_unavailable", "The Workflow platform status is temporarily unavailable.");
|
|
655
|
+
}
|
|
656
|
+
const reset = await env.DB.prepare(`
|
|
657
|
+
UPDATE workflow_dispatch_outbox SET state = 'pending', attempt_count = 0,
|
|
658
|
+
available_at = ?2, lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2
|
|
659
|
+
WHERE id = ?1 AND state IN ('failed','dead_letter','leased')
|
|
660
|
+
`).bind(dispatchFailure.outboxId, timestamp).run();
|
|
661
|
+
if (reset.meta.changes !== 1) {
|
|
662
|
+
throw new ApiError(503, "workflow_platform_status_unavailable", "The Workflow platform status is temporarily unavailable.");
|
|
663
|
+
}
|
|
664
|
+
state = "dispatch_pending";
|
|
665
|
+
repairApplied = true;
|
|
666
|
+
statements.push(
|
|
667
|
+
env.DB.prepare(`
|
|
668
|
+
UPDATE workflow_runs SET state = 'dispatch_pending', cloudflare_instance_id = NULL,
|
|
669
|
+
error_code = NULL, error_summary = NULL, updated_at = ?2, revision = revision + 1
|
|
670
|
+
WHERE id = ?1 AND state = 'repair_required'
|
|
671
|
+
`).bind(runId, timestamp),
|
|
672
|
+
);
|
|
626
673
|
}
|
|
627
|
-
platformState = status
|
|
628
|
-
const mapped = status
|
|
674
|
+
platformState = status?.status ?? null;
|
|
675
|
+
const mapped = !status ? null
|
|
676
|
+
: status.status === "complete" ? "succeeded"
|
|
629
677
|
: status.status === "errored" ? "failed"
|
|
630
678
|
: status.status === "terminated" ? "terminated"
|
|
631
679
|
: status.status === "waiting" || status.status === "waitingForPause" ? "waiting"
|
|
@@ -642,7 +690,7 @@ export async function reconcileWorkflowRun(
|
|
|
642
690
|
waiting_at = CASE WHEN ?2 = 'waiting' THEN COALESCE(waiting_at, ?4) ELSE NULL END,
|
|
643
691
|
finished_at = CASE WHEN ?2 IN ('succeeded','failed','terminated') THEN COALESCE(finished_at, ?4) ELSE finished_at END,
|
|
644
692
|
updated_at = ?4, revision = revision + 1 WHERE id = ?1
|
|
645
|
-
`).bind(runId, mapped, status
|
|
693
|
+
`).bind(runId, mapped, status?.error?.message?.slice(0, 500) ?? null, timestamp),
|
|
646
694
|
);
|
|
647
695
|
} else if (mapped && terminal && mapped !== run.state) {
|
|
648
696
|
const incidentKey = `workflow-state-mismatch:${runId}:${run.state}:${mapped}`;
|
|
@@ -657,7 +705,7 @@ export async function reconcileWorkflowRun(
|
|
|
657
705
|
last_seen_at = excluded.last_seen_at, metadata_json = excluded.metadata_json
|
|
658
706
|
`).bind(
|
|
659
707
|
crypto.randomUUID(), incidentKey, "A terminal D1 workflow state differs from the Cloudflare Workflow instance state.",
|
|
660
|
-
runId, timestamp, canonicalJson({ d1State: run.state, platformState: mapped, platformRawState: status
|
|
708
|
+
runId, timestamp, canonicalJson({ d1State: run.state, platformState: mapped, platformRawState: status?.status ?? null }),
|
|
661
709
|
));
|
|
662
710
|
}
|
|
663
711
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalJson } from "./audit";
|
|
2
|
-
import { evaluateInputMapping, evaluateWorkflowExpression } from "./workflow-expressions";
|
|
2
|
+
import { evaluateInputMapping, evaluateWorkflowExpression, validateJsonSchemaValue } from "./workflow-expressions";
|
|
3
3
|
import type { JsonObject, JsonValue, WorkflowGraphV1, WorkflowNodeV1 } from "./workflow-platform-types";
|
|
4
4
|
|
|
5
5
|
export interface WorkflowSimulationFixture {
|
|
@@ -63,7 +63,8 @@ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimula
|
|
|
63
63
|
const outgoing = new Map(graph.nodes.map((node) => [node.id, graph.edges.filter((edge) => edge.from.nodeId === node.id)]));
|
|
64
64
|
const steps: WorkflowSimulationStep[] = [];
|
|
65
65
|
const stepOutputs: JsonObject = {};
|
|
66
|
-
|
|
66
|
+
// Fixtures use the same named context and wait payload shape as the runtime.
|
|
67
|
+
const baseContext: JsonObject = { ...fixture.input, input: fixture.input, run: fixture.input.run ?? { id: "fixture-run" }, steps: stepOutputs, findings: fixture.input.findings ?? {} };
|
|
67
68
|
|
|
68
69
|
const record = (node: WorkflowNodeV1, branchKey: string, state: WorkflowSimulationStep["state"], port: string, output: JsonObject) => {
|
|
69
70
|
const entry = { nodeId: node.id, kind: node.kind, branchKey, state, port, output };
|
|
@@ -134,7 +135,9 @@ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimula
|
|
|
134
135
|
state = "timed_out";
|
|
135
136
|
last = { port: "timeout", status: "timed_out" };
|
|
136
137
|
} else {
|
|
137
|
-
|
|
138
|
+
const errors = validateJsonSchemaValue(response, object(node.config.response_schema));
|
|
139
|
+
if (errors.length) throw new SimulationFailure("simulation_human_response_invalid", node.id);
|
|
140
|
+
last = { port: "completed", status: "received", event: { task_id: `fixture:${node.id}`, response_id: `fixture-response:${node.id}`, response } };
|
|
138
141
|
}
|
|
139
142
|
} else if (node.kind === "wait_for_event") {
|
|
140
143
|
const response = fixture.humanResponses[node.id];
|
|
@@ -150,7 +153,7 @@ export function simulateWorkflow(graph: WorkflowGraphV1, fixture: WorkflowSimula
|
|
|
150
153
|
last = { port: "sent", simulated: true, audience: node.config.audience ?? null };
|
|
151
154
|
} else if (node.kind === "decision") {
|
|
152
155
|
state = "simulated";
|
|
153
|
-
last = { port: "recorded", simulated: true, decision: mapped };
|
|
156
|
+
last = { port: "recorded", simulated: true, decisionId: `fixture:${node.id}`, decisionCode: mapped.decision_code ?? null, decision: mapped };
|
|
154
157
|
} else if (node.kind === "action") {
|
|
155
158
|
state = "simulated";
|
|
156
159
|
last = { port: "applied", simulated: true, action: mapped };
|