@blokjs/runner 0.2.2 → 0.6.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/dist/Blok.js +32 -3
- package/dist/Blok.js.map +1 -1
- package/dist/Configuration.d.ts +59 -5
- package/dist/Configuration.js +366 -96
- package/dist/Configuration.js.map +1 -1
- package/dist/ForEachNode.d.ts +59 -0
- package/dist/ForEachNode.js +522 -0
- package/dist/ForEachNode.js.map +1 -0
- package/dist/LoopMaxIterationsError.d.ts +11 -0
- package/dist/LoopMaxIterationsError.js +18 -0
- package/dist/LoopMaxIterationsError.js.map +1 -0
- package/dist/LoopNode.d.ts +36 -0
- package/dist/LoopNode.js +182 -0
- package/dist/LoopNode.js.map +1 -0
- package/dist/PayloadTooLargeError.d.ts +19 -0
- package/dist/PayloadTooLargeError.js +29 -0
- package/dist/PayloadTooLargeError.js.map +1 -0
- package/dist/RunCancelledError.d.ts +17 -0
- package/dist/RunCancelledError.js +25 -0
- package/dist/RunCancelledError.js.map +1 -0
- package/dist/Runner.d.ts +11 -1
- package/dist/Runner.js +9 -2
- package/dist/Runner.js.map +1 -1
- package/dist/RunnerSteps.js +648 -44
- package/dist/RunnerSteps.js.map +1 -1
- package/dist/RuntimeAdapterNode.d.ts +2 -1
- package/dist/RuntimeAdapterNode.js +2 -2
- package/dist/RuntimeAdapterNode.js.map +1 -1
- package/dist/RuntimeRegistry.d.ts +23 -2
- package/dist/RuntimeRegistry.js +31 -2
- package/dist/RuntimeRegistry.js.map +1 -1
- package/dist/SubworkflowNode.d.ts +181 -0
- package/dist/SubworkflowNode.js +479 -0
- package/dist/SubworkflowNode.js.map +1 -0
- package/dist/SwitchNode.d.ts +37 -0
- package/dist/SwitchNode.js +153 -0
- package/dist/SwitchNode.js.map +1 -0
- package/dist/TriggerBase.d.ts +178 -0
- package/dist/TriggerBase.js +1032 -5
- package/dist/TriggerBase.js.map +1 -1
- package/dist/TryCatchNode.d.ts +32 -0
- package/dist/TryCatchNode.js +207 -0
- package/dist/TryCatchNode.js.map +1 -0
- package/dist/WaitDispatchRequest.d.ts +38 -0
- package/dist/WaitDispatchRequest.js +13 -0
- package/dist/WaitDispatchRequest.js.map +1 -0
- package/dist/WaitNode.d.ts +23 -0
- package/dist/WaitNode.js +26 -0
- package/dist/WaitNode.js.map +1 -0
- package/dist/adapters/grpc/GrpcCodec.js +2 -2
- package/dist/adapters/grpc/GrpcRuntimeAdapter.d.ts +6 -4
- package/dist/adapters/grpc/GrpcRuntimeAdapter.js +6 -4
- package/dist/adapters/grpc/GrpcRuntimeAdapter.js.map +1 -1
- package/dist/adapters/grpc/types.d.ts +7 -5
- package/dist/adapters/grpc/types.js.map +1 -1
- package/dist/adapters/transport.d.ts +12 -41
- package/dist/adapters/transport.js +21 -70
- package/dist/adapters/transport.js.map +1 -1
- package/dist/cache/NodeResultCache.js +7 -0
- package/dist/cache/NodeResultCache.js.map +1 -1
- package/dist/concurrency/ConcurrencyBackend.d.ts +61 -0
- package/dist/concurrency/ConcurrencyBackend.js +20 -0
- package/dist/concurrency/ConcurrencyBackend.js.map +1 -0
- package/dist/concurrency/ConcurrencyLimitError.d.ts +37 -0
- package/dist/concurrency/ConcurrencyLimitError.js +16 -0
- package/dist/concurrency/ConcurrencyLimitError.js.map +1 -0
- package/dist/concurrency/NatsKvConcurrencyBackend.d.ts +64 -0
- package/dist/concurrency/NatsKvConcurrencyBackend.js +310 -0
- package/dist/concurrency/NatsKvConcurrencyBackend.js.map +1 -0
- package/dist/concurrency/QueueExpiredError.d.ts +40 -0
- package/dist/concurrency/QueueExpiredError.js +15 -0
- package/dist/concurrency/QueueExpiredError.js.map +1 -0
- package/dist/concurrency/RedisConcurrencyBackend.d.ts +64 -0
- package/dist/concurrency/RedisConcurrencyBackend.js +374 -0
- package/dist/concurrency/RedisConcurrencyBackend.js.map +1 -0
- package/dist/concurrency/createConcurrencyBackend.d.ts +24 -0
- package/dist/concurrency/createConcurrencyBackend.js +38 -0
- package/dist/concurrency/createConcurrencyBackend.js.map +1 -0
- package/dist/concurrency/readConcurrencyConfig.d.ts +60 -0
- package/dist/concurrency/readConcurrencyConfig.js +60 -0
- package/dist/concurrency/readConcurrencyConfig.js.map +1 -0
- package/dist/defineNode.d.ts +8 -0
- package/dist/defineNode.js +25 -5
- package/dist/defineNode.js.map +1 -1
- package/dist/graphql/GraphQLSchemaGenerator.js +1 -1
- package/dist/graphql/GraphQLSchemaGenerator.js.map +1 -1
- package/dist/idempotency/resolveIdempotencyKey.d.ts +20 -0
- package/dist/idempotency/resolveIdempotencyKey.js +37 -0
- package/dist/idempotency/resolveIdempotencyKey.js.map +1 -0
- package/dist/index.d.ts +30 -6
- package/dist/index.js +55 -6
- package/dist/index.js.map +1 -1
- package/dist/marketplace/RuntimeCatalog.d.ts +6 -0
- package/dist/marketplace/RuntimeCatalog.js.map +1 -1
- package/dist/marketplace/RuntimeDiscovery.d.ts +2 -2
- package/dist/marketplace/RuntimeDiscovery.js +18 -6
- package/dist/marketplace/RuntimeDiscovery.js.map +1 -1
- package/dist/monitoring/ConcurrencyMetrics.d.ts +82 -0
- package/dist/monitoring/ConcurrencyMetrics.js +139 -0
- package/dist/monitoring/ConcurrencyMetrics.js.map +1 -0
- package/dist/monitoring/ForEachWaitMetrics.d.ts +22 -0
- package/dist/monitoring/ForEachWaitMetrics.js +36 -0
- package/dist/monitoring/ForEachWaitMetrics.js.map +1 -0
- package/dist/monitoring/JanitorMetrics.d.ts +27 -0
- package/dist/monitoring/JanitorMetrics.js +48 -0
- package/dist/monitoring/JanitorMetrics.js.map +1 -0
- package/dist/openapi/OpenAPIGenerator.js +7 -2
- package/dist/openapi/OpenAPIGenerator.js.map +1 -1
- package/dist/runtime/PrimitiveStack.d.ts +64 -0
- package/dist/runtime/PrimitiveStack.js +92 -0
- package/dist/runtime/PrimitiveStack.js.map +1 -0
- package/dist/scheduling/DebounceBackend.d.ts +108 -0
- package/dist/scheduling/DebounceBackend.js +23 -0
- package/dist/scheduling/DebounceBackend.js.map +1 -0
- package/dist/scheduling/DebounceCoordinator.d.ts +141 -0
- package/dist/scheduling/DebounceCoordinator.js +362 -0
- package/dist/scheduling/DebounceCoordinator.js.map +1 -0
- package/dist/scheduling/DeferredDispatchSignal.d.ts +50 -0
- package/dist/scheduling/DeferredDispatchSignal.js +14 -0
- package/dist/scheduling/DeferredDispatchSignal.js.map +1 -0
- package/dist/scheduling/DeferredRunScheduler.d.ts +96 -0
- package/dist/scheduling/DeferredRunScheduler.js +256 -0
- package/dist/scheduling/DeferredRunScheduler.js.map +1 -0
- package/dist/scheduling/NatsKvDebounceBackend.d.ts +53 -0
- package/dist/scheduling/NatsKvDebounceBackend.js +334 -0
- package/dist/scheduling/NatsKvDebounceBackend.js.map +1 -0
- package/dist/scheduling/RedisDebounceBackend.d.ts +49 -0
- package/dist/scheduling/RedisDebounceBackend.js +356 -0
- package/dist/scheduling/RedisDebounceBackend.js.map +1 -0
- package/dist/scheduling/createDebounceBackend.d.ts +25 -0
- package/dist/scheduling/createDebounceBackend.js +39 -0
- package/dist/scheduling/createDebounceBackend.js.map +1 -0
- package/dist/scheduling/readSchedulingConfig.d.ts +24 -0
- package/dist/scheduling/readSchedulingConfig.js +52 -0
- package/dist/scheduling/readSchedulingConfig.js.map +1 -0
- package/dist/security/AuditLogger.js +1 -1
- package/dist/security/AuditLogger.js.map +1 -1
- package/dist/security/AuthMiddleware.d.ts +19 -20
- package/dist/security/AuthMiddleware.js +35 -20
- package/dist/security/AuthMiddleware.js.map +1 -1
- package/dist/security/OAuthProvider.js +2 -2
- package/dist/security/OAuthProvider.js.map +1 -1
- package/dist/security/SecretManager.js +14 -13
- package/dist/security/SecretManager.js.map +1 -1
- package/dist/security/index.d.ts +3 -1
- package/dist/security/index.js +3 -1
- package/dist/security/index.js.map +1 -1
- package/dist/testing/TestHarness.d.ts +27 -12
- package/dist/testing/TestHarness.js +19 -3
- package/dist/testing/TestHarness.js.map +1 -1
- package/dist/testing/WorkflowTestRunner.js +0 -7
- package/dist/testing/WorkflowTestRunner.js.map +1 -1
- package/dist/timeouts/StepTimeoutError.d.ts +22 -0
- package/dist/timeouts/StepTimeoutError.js +31 -0
- package/dist/timeouts/StepTimeoutError.js.map +1 -0
- package/dist/tracing/InMemoryRunStore.d.ts +41 -1
- package/dist/tracing/InMemoryRunStore.js +239 -0
- package/dist/tracing/InMemoryRunStore.js.map +1 -1
- package/dist/tracing/Janitor.d.ts +70 -0
- package/dist/tracing/Janitor.js +150 -0
- package/dist/tracing/Janitor.js.map +1 -0
- package/dist/tracing/PostgresRunStore.d.ts +57 -1
- package/dist/tracing/PostgresRunStore.js +711 -6
- package/dist/tracing/PostgresRunStore.js.map +1 -1
- package/dist/tracing/RoutingDiagnostics.d.ts +55 -0
- package/dist/tracing/RoutingDiagnostics.js +50 -0
- package/dist/tracing/RoutingDiagnostics.js.map +1 -0
- package/dist/tracing/RunStore.d.ts +181 -1
- package/dist/tracing/RunTracker.d.ts +244 -9
- package/dist/tracing/RunTracker.js +594 -1
- package/dist/tracing/RunTracker.js.map +1 -1
- package/dist/tracing/SqliteRunStore.d.ts +79 -2
- package/dist/tracing/SqliteRunStore.js +775 -16
- package/dist/tracing/SqliteRunStore.js.map +1 -1
- package/dist/tracing/TraceRouter.d.ts +20 -2
- package/dist/tracing/TraceRouter.js +612 -6
- package/dist/tracing/TraceRouter.js.map +1 -1
- package/dist/tracing/createStore.js +14 -3
- package/dist/tracing/createStore.js.map +1 -1
- package/dist/tracing/metadataFilter.d.ts +63 -0
- package/dist/tracing/metadataFilter.js +224 -0
- package/dist/tracing/metadataFilter.js.map +1 -0
- package/dist/tracing/sanitize.d.ts +11 -0
- package/dist/tracing/sanitize.js +29 -0
- package/dist/tracing/sanitize.js.map +1 -1
- package/dist/tracing/types.d.ts +672 -2
- package/dist/utils/createChildContext.d.ts +32 -0
- package/dist/utils/createChildContext.js +113 -0
- package/dist/utils/createChildContext.js.map +1 -0
- package/dist/utils/envAllowlist.d.ts +35 -0
- package/dist/utils/envAllowlist.js +113 -0
- package/dist/utils/envAllowlist.js.map +1 -0
- package/dist/version/RuntimeVersionValidator.d.ts +38 -0
- package/dist/version/RuntimeVersionValidator.js +121 -0
- package/dist/version/RuntimeVersionValidator.js.map +1 -0
- package/dist/visualization/WorkflowVisualizer.js +4 -4
- package/dist/visualization/WorkflowVisualizer.js.map +1 -1
- package/dist/workflow/PersistenceHelper.d.ts +18 -10
- package/dist/workflow/PersistenceHelper.js +35 -9
- package/dist/workflow/PersistenceHelper.js.map +1 -1
- package/dist/workflow/WorkflowNormalizer.d.ts +48 -42
- package/dist/workflow/WorkflowNormalizer.js +650 -18
- package/dist/workflow/WorkflowNormalizer.js.map +1 -1
- package/dist/workflow/WorkflowRegistry.d.ts +186 -0
- package/dist/workflow/WorkflowRegistry.js +202 -0
- package/dist/workflow/WorkflowRegistry.js.map +1 -0
- package/dist/workflow/sampleBody.d.ts +54 -0
- package/dist/workflow/sampleBody.js +320 -0
- package/dist/workflow/sampleBody.js.map +1 -0
- package/package.json +3 -8
- package/dist/adapters/HttpRuntimeAdapter.d.ts +0 -79
- package/dist/adapters/HttpRuntimeAdapter.js +0 -233
- package/dist/adapters/HttpRuntimeAdapter.js.map +0 -1
|
@@ -1,5 +1,191 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { DebounceCoordinator } from "../scheduling/DebounceCoordinator";
|
|
3
|
+
import { DeferredRunScheduler } from "../scheduling/DeferredRunScheduler";
|
|
4
|
+
import { WorkflowRegistry } from "../workflow/WorkflowRegistry";
|
|
5
|
+
import { inferSampleBody } from "../workflow/sampleBody";
|
|
6
|
+
import { RoutingDiagnostics } from "./RoutingDiagnostics";
|
|
2
7
|
import { RunTracker } from "./RunTracker";
|
|
8
|
+
import { METADATA_OPERATORS, isValidMetadataKey } from "./metadataFilter";
|
|
9
|
+
/**
|
|
10
|
+
* Security review FW-2 — sensitive headers that are NEVER honored when
|
|
11
|
+
* supplied via the replay endpoint's `overrides.headers`. Combined with
|
|
12
|
+
* the FW-1 trace-auth gate, this blocks the replay-as-auth-bypass attack
|
|
13
|
+
* where an unauthenticated client posts to `/__blok/runs/:id/replay`
|
|
14
|
+
* with an attacker-controlled `Authorization` header that the runner
|
|
15
|
+
* would otherwise dispatch verbatim to the user-authored route.
|
|
16
|
+
*/
|
|
17
|
+
const REPLAY_HEADER_DENYLIST = new Set([
|
|
18
|
+
"authorization",
|
|
19
|
+
"cookie",
|
|
20
|
+
"set-cookie",
|
|
21
|
+
"x-api-key",
|
|
22
|
+
"x-auth-token",
|
|
23
|
+
"proxy-authorization",
|
|
24
|
+
]);
|
|
25
|
+
function filterReplayHeaders(headers) {
|
|
26
|
+
if (!headers)
|
|
27
|
+
return {};
|
|
28
|
+
const filtered = {};
|
|
29
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
30
|
+
if (REPLAY_HEADER_DENYLIST.has(k.toLowerCase()))
|
|
31
|
+
continue;
|
|
32
|
+
filtered[k] = v;
|
|
33
|
+
}
|
|
34
|
+
return filtered;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Coerce a query-string `?limit=...` / `?offset=...` value to a clamped
|
|
38
|
+
* integer. Strings parse via `Number.parseInt`; anything non-finite (or
|
|
39
|
+
* outside `[min, max]`) falls back to `fallback`. Used by paginated GET
|
|
40
|
+
* endpoints so Studio queries can't pin the event loop with absurd
|
|
41
|
+
* window sizes.
|
|
42
|
+
*/
|
|
43
|
+
function clampInt(raw, min, max, fallback) {
|
|
44
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
45
|
+
return fallback;
|
|
46
|
+
const n = Number.parseInt(raw, 10);
|
|
47
|
+
if (!Number.isFinite(n) || Number.isNaN(n))
|
|
48
|
+
return fallback;
|
|
49
|
+
if (n < min)
|
|
50
|
+
return min;
|
|
51
|
+
if (n > max)
|
|
52
|
+
return max;
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* F2 (v0.5) — parse `metadata.<key>[__op]=<value>` query params into
|
|
57
|
+
* the operator-aware `MetadataFilter[]` shape.
|
|
58
|
+
*
|
|
59
|
+
* Supported suffixes (`__op`): `eq` (default), `ne`, `gt`, `gte`,
|
|
60
|
+
* `lt`, `lte`, `like`, `in`, `nin`. Unknown suffixes silently drop —
|
|
61
|
+
* preserves the v0.4 contract that unrecognised query keys are ignored
|
|
62
|
+
* rather than rejected (operators got the same treatment as the v0.4
|
|
63
|
+
* key-shape validation).
|
|
64
|
+
*
|
|
65
|
+
* `in` / `nin` values are comma-split:
|
|
66
|
+
* `metadata.region__in=us,eu,ap` → value: ["us", "eu", "ap"]
|
|
67
|
+
*
|
|
68
|
+
* Keys outside `^[a-zA-Z0-9_-]+$` silently drop — same SQL-injection
|
|
69
|
+
* guard the v0.4 parser already applied.
|
|
70
|
+
*/
|
|
71
|
+
function parseMetadataFiltersFromQuery(query) {
|
|
72
|
+
let filters;
|
|
73
|
+
const opSet = new Set(METADATA_OPERATORS);
|
|
74
|
+
for (const [rawKey, value] of Object.entries(query)) {
|
|
75
|
+
if (!rawKey.startsWith("metadata."))
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof value !== "string" || value.length === 0)
|
|
78
|
+
continue;
|
|
79
|
+
const remainder = rawKey.slice("metadata.".length);
|
|
80
|
+
if (remainder.length === 0)
|
|
81
|
+
continue;
|
|
82
|
+
// Suffix split — accepts `key`, `key__op`. Multiple `__` in a
|
|
83
|
+
// key name are tolerated; only the FINAL `__op` is interpreted
|
|
84
|
+
// as the operator when it matches the operator set.
|
|
85
|
+
const opIdx = remainder.lastIndexOf("__");
|
|
86
|
+
let metaKey;
|
|
87
|
+
let op;
|
|
88
|
+
if (opIdx > 0 && opSet.has(remainder.slice(opIdx + 2))) {
|
|
89
|
+
metaKey = remainder.slice(0, opIdx);
|
|
90
|
+
op = remainder.slice(opIdx + 2);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
metaKey = remainder;
|
|
94
|
+
op = "eq";
|
|
95
|
+
}
|
|
96
|
+
if (!isValidMetadataKey(metaKey))
|
|
97
|
+
continue;
|
|
98
|
+
const parsedValue = op === "in" || op === "nin"
|
|
99
|
+
? value
|
|
100
|
+
.split(",")
|
|
101
|
+
.map((s) => s.trim())
|
|
102
|
+
.filter((s) => s.length > 0)
|
|
103
|
+
: value;
|
|
104
|
+
if (Array.isArray(parsedValue) && parsedValue.length === 0)
|
|
105
|
+
continue;
|
|
106
|
+
if (!filters)
|
|
107
|
+
filters = [];
|
|
108
|
+
filters.push({ key: metaKey, op, value: parsedValue });
|
|
109
|
+
}
|
|
110
|
+
return filters;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Strip sensitive request headers from a scheduled-dispatch payload
|
|
114
|
+
* before serving it to Studio. `extractDispatchPayload` already strips
|
|
115
|
+
* these at PERSIST time (see `HttpTrigger.extractDispatchPayload`); we
|
|
116
|
+
* re-apply the denylist on read as a belt-and-braces guard against
|
|
117
|
+
* older sqlite rows that pre-date that strip path.
|
|
118
|
+
*/
|
|
119
|
+
function sanitizeDispatchPayload(payload) {
|
|
120
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
121
|
+
return payload;
|
|
122
|
+
const obj = payload;
|
|
123
|
+
const headers = obj.headers;
|
|
124
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers))
|
|
125
|
+
return payload;
|
|
126
|
+
const filtered = {};
|
|
127
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
128
|
+
if (REPLAY_HEADER_DENYLIST.has(k.toLowerCase()))
|
|
129
|
+
continue;
|
|
130
|
+
filtered[k] = v;
|
|
131
|
+
}
|
|
132
|
+
return { ...obj, headers: filtered };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Synthesize a zero-stat `WorkflowSummary` from a `WorkflowRegistry`
|
|
136
|
+
* entry that has never run. Studio's sidebar consumes `/__blok/workflows`,
|
|
137
|
+
* so without this merge a workflow that's registered + bound to a URL
|
|
138
|
+
* but hasn't been triggered yet is invisible to the operator — they
|
|
139
|
+
* have no row to click into, can't open the Graph tab, and (the bug
|
|
140
|
+
* that motivated this) can't even tell the workflow exists.
|
|
141
|
+
*
|
|
142
|
+
* Returns `null` for middleware-only workflows (`isMiddleware: true`)
|
|
143
|
+
* and for entries without a recognised trigger kind — both are
|
|
144
|
+
* non-user-facing.
|
|
145
|
+
*/
|
|
146
|
+
function synthesizeRegistryOnlySummary(reg) {
|
|
147
|
+
if (reg.isMiddleware)
|
|
148
|
+
return null;
|
|
149
|
+
const wf = reg.workflow;
|
|
150
|
+
if (!wf || typeof wf !== "object")
|
|
151
|
+
return null;
|
|
152
|
+
const trigger = wf.trigger;
|
|
153
|
+
if (!trigger || typeof trigger !== "object")
|
|
154
|
+
return null;
|
|
155
|
+
const triggerTypes = [];
|
|
156
|
+
let primaryPath;
|
|
157
|
+
for (const [kind, raw] of Object.entries(trigger)) {
|
|
158
|
+
if (!raw || typeof raw !== "object")
|
|
159
|
+
continue;
|
|
160
|
+
triggerTypes.push(kind);
|
|
161
|
+
if (primaryPath !== undefined)
|
|
162
|
+
continue;
|
|
163
|
+
// Pick the first identifying field that exists. Mirrors the
|
|
164
|
+
// `workflow_path` column the SQL summaries return — that's the
|
|
165
|
+
// runtime-set URL, here we approximate with the trigger config.
|
|
166
|
+
const r = raw;
|
|
167
|
+
if (typeof r.path === "string")
|
|
168
|
+
primaryPath = r.path;
|
|
169
|
+
else if (typeof r.queue === "string")
|
|
170
|
+
primaryPath = r.queue;
|
|
171
|
+
else if (typeof r.schedule === "string")
|
|
172
|
+
primaryPath = r.schedule;
|
|
173
|
+
else if (typeof r.topic === "string")
|
|
174
|
+
primaryPath = r.topic;
|
|
175
|
+
}
|
|
176
|
+
if (triggerTypes.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
return {
|
|
179
|
+
name: reg.name,
|
|
180
|
+
path: primaryPath ?? "/",
|
|
181
|
+
triggerTypes,
|
|
182
|
+
totalRuns: 0,
|
|
183
|
+
recentRuns: 0,
|
|
184
|
+
errorRate: 0,
|
|
185
|
+
avgDurationMs: 0,
|
|
186
|
+
p95DurationMs: 0,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
3
189
|
/**
|
|
4
190
|
* Register trace API routes on an Express-compatible router.
|
|
5
191
|
*
|
|
@@ -12,22 +198,64 @@ import { RunTracker } from "./RunTracker";
|
|
|
12
198
|
* import { Router } from "express";
|
|
13
199
|
* import { registerTraceRoutes } from "@blokjs/runner";
|
|
14
200
|
* const traceRouter = Router();
|
|
15
|
-
* registerTraceRoutes(traceRouter);
|
|
201
|
+
* registerTraceRoutes(traceRouter, undefined, { authorize: myAuthFn });
|
|
16
202
|
* app.use("/__blok", traceRouter);
|
|
17
203
|
* ```
|
|
18
204
|
*/
|
|
19
|
-
export function registerTraceRoutes(router, tracker) {
|
|
205
|
+
export function registerTraceRoutes(router, tracker, options) {
|
|
20
206
|
const t = tracker || RunTracker.getInstance();
|
|
21
207
|
// --- CORS for cross-origin Studio UI ---
|
|
208
|
+
// Security review FW-4 — `BLOK_TRACE_CORS_ORIGIN` overrides the
|
|
209
|
+
// permissive `*` default. Set to a single allow-listed origin in
|
|
210
|
+
// production to prevent cross-origin reads of trace data.
|
|
211
|
+
const corsOrigin = process.env.BLOK_TRACE_CORS_ORIGIN || "*";
|
|
212
|
+
// Security review FW-1 — production-default-deny on /__blok/* unless
|
|
213
|
+
// the operator either registers an authorize hook (preferred) or
|
|
214
|
+
// explicitly opts out via BLOK_TRACE_AUTH_DISABLED=1.
|
|
215
|
+
const isProd = process.env.BLOK_ENV === "production" || process.env.NODE_ENV === "production";
|
|
216
|
+
const authDisabled = process.env.BLOK_TRACE_AUTH_DISABLED === "1";
|
|
217
|
+
const authorize = options?.authorize;
|
|
22
218
|
router.use((req, res, next) => {
|
|
23
|
-
res.setHeader("Access-Control-Allow-Origin",
|
|
219
|
+
res.setHeader("Access-Control-Allow-Origin", corsOrigin);
|
|
24
220
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
25
221
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Last-Event-ID");
|
|
26
222
|
if (req.method === "OPTIONS") {
|
|
27
223
|
res.sendStatus(204);
|
|
28
224
|
return;
|
|
29
225
|
}
|
|
30
|
-
|
|
226
|
+
// Dev OR explicit opt-out → pass through (preserves previous behaviour).
|
|
227
|
+
if (!isProd || authDisabled) {
|
|
228
|
+
next();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Production WITHOUT an authorize hook → 503 with a hint.
|
|
232
|
+
if (!authorize) {
|
|
233
|
+
res.status(503).json({
|
|
234
|
+
error: "Trace endpoints require auth in production",
|
|
235
|
+
hint: "Register an authorize hook before listen() — `trigger.setTraceAuth(req => ...)` — or set BLOK_TRACE_AUTH_DISABLED=1 to opt out (typically because /__blok/* is already firewalled).",
|
|
236
|
+
docs: "https://github.com/deskree-inc/blok/blob/main/docs/d/security/cookbook.mdx#secure-the-trace-api-and-studio",
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Production WITH an authorize hook → consult it. Wrap in
|
|
241
|
+
// `Promise.resolve().then(...)` so a SYNC throw inside the
|
|
242
|
+
// authorize function is caught the same as an async rejection.
|
|
243
|
+
Promise.resolve()
|
|
244
|
+
.then(() => authorize(req))
|
|
245
|
+
.then((ok) => {
|
|
246
|
+
if (ok) {
|
|
247
|
+
next();
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
.catch((err) => {
|
|
254
|
+
// Don't leak the underlying error message — log it once,
|
|
255
|
+
// return a generic 401.
|
|
256
|
+
console.error("[blok][trace-auth] authorize() threw:", err?.message ?? err);
|
|
257
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
258
|
+
});
|
|
31
259
|
});
|
|
32
260
|
// === Utility Endpoints ===
|
|
33
261
|
router.get("/health", (_req, res) => {
|
|
@@ -47,12 +275,43 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
47
275
|
// === Workflow Endpoints ===
|
|
48
276
|
router.get("/workflows", (_req, res) => {
|
|
49
277
|
const summaries = t.getWorkflowSummaries();
|
|
278
|
+
// E4 follow-up — `getWorkflowSummaries()` derives only from the
|
|
279
|
+
// `workflow_runs` table, so workflows that have been registered
|
|
280
|
+
// but never run don't show up. Studio uses this endpoint to power
|
|
281
|
+
// the sidebar; without merging the registry the user has no way
|
|
282
|
+
// to navigate to a workflow before it executes, including no way
|
|
283
|
+
// to see its static DAG. Synthesize a zero-stat summary for
|
|
284
|
+
// every registered workflow not already present.
|
|
285
|
+
const seen = new Set(summaries.map((s) => s.name));
|
|
286
|
+
for (const reg of WorkflowRegistry.getInstance().list()) {
|
|
287
|
+
if (seen.has(reg.name))
|
|
288
|
+
continue;
|
|
289
|
+
const synthesized = synthesizeRegistryOnlySummary(reg);
|
|
290
|
+
if (synthesized)
|
|
291
|
+
summaries.push(synthesized);
|
|
292
|
+
}
|
|
50
293
|
res.json(summaries);
|
|
51
294
|
});
|
|
52
295
|
router.get("/workflows/:name", (req, res) => {
|
|
53
296
|
const { name } = req.params;
|
|
54
297
|
const summaries = t.getWorkflowSummaries();
|
|
55
|
-
|
|
298
|
+
let summary = summaries.find((s) => s.name === name);
|
|
299
|
+
// E4 — surface the raw workflow JSON (pre-normalization) from
|
|
300
|
+
// the registry so Studio can render the static DAG. Triggers
|
|
301
|
+
// feed the registry at boot; if the workflow was registered
|
|
302
|
+
// inline (e.g. tests with no source file) it's still here.
|
|
303
|
+
const registered = WorkflowRegistry.getInstance().get(name);
|
|
304
|
+
// Sidebar follow-up (#99) — if the workflow is registered but
|
|
305
|
+
// has never run, `getWorkflowSummaries()` won't return a row
|
|
306
|
+
// for it (SQL aggregation derives from `workflow_runs`). Fall
|
|
307
|
+
// back to a synthesized zero-stat summary so Studio's detail
|
|
308
|
+
// page renders + the Graph tab AND the empty-state curl example
|
|
309
|
+
// are reachable on first sight.
|
|
310
|
+
if (!summary && registered) {
|
|
311
|
+
const synthesized = synthesizeRegistryOnlySummary(registered);
|
|
312
|
+
if (synthesized)
|
|
313
|
+
summary = synthesized;
|
|
314
|
+
}
|
|
56
315
|
if (!summary) {
|
|
57
316
|
res.status(404).json({ error: `Workflow '${name}' not found` });
|
|
58
317
|
return;
|
|
@@ -69,12 +328,70 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
69
328
|
runtimes.add(node.runtimeKind);
|
|
70
329
|
}
|
|
71
330
|
}
|
|
331
|
+
// Sample-body resolution. Priority (highest first):
|
|
332
|
+
// 1. Author override: `trigger.http.examples.body` in the
|
|
333
|
+
// workflow JSON (#100). Source of truth — never overridden.
|
|
334
|
+
// 2. Recorded sample: captured from the first successful run
|
|
335
|
+
// when `trigger.http.recordSample: true` (option C / v0.6).
|
|
336
|
+
// Real-world body that exercised the workflow.
|
|
337
|
+
// 3. Static inference: walk step references for
|
|
338
|
+
// `ctx.request.body.<path>` (#100). Heuristic placeholder.
|
|
339
|
+
// 4. Empty `{}` — fallback when nothing else exists.
|
|
340
|
+
// The `inferSampleBody()` helper already handles #1 vs #3; we
|
|
341
|
+
// slot the recorded sample in between by overriding the `source`
|
|
342
|
+
// + `body` when one exists AND the helper didn't fall through to
|
|
343
|
+
// an author override.
|
|
344
|
+
const inferred = registered?.workflow ? inferSampleBody(registered.workflow) : null;
|
|
345
|
+
const recorded = t.getWorkflowSample(name);
|
|
346
|
+
let examples;
|
|
347
|
+
if (inferred?.source === "author") {
|
|
348
|
+
examples = { body: inferred.body, source: "author" };
|
|
349
|
+
}
|
|
350
|
+
else if (recorded) {
|
|
351
|
+
examples = { body: recorded.body, source: "recorded" };
|
|
352
|
+
}
|
|
353
|
+
else if (inferred) {
|
|
354
|
+
examples = { body: inferred.body, source: inferred.source };
|
|
355
|
+
}
|
|
72
356
|
res.json({
|
|
73
357
|
...summary,
|
|
74
358
|
nodeNames: Array.from(nodeNames),
|
|
75
359
|
runtimes: Array.from(runtimes),
|
|
360
|
+
definition: registered?.workflow,
|
|
361
|
+
examples,
|
|
76
362
|
});
|
|
77
363
|
});
|
|
364
|
+
// E4 follow-up — surface boot-time route-build errors (collisions,
|
|
365
|
+
// missing paths) so Studio can render a banner on the Workflows page
|
|
366
|
+
// rather than burying the issue in terminal logs. The trigger
|
|
367
|
+
// populates `RoutingDiagnostics` at boot via `buildRouteTable` in
|
|
368
|
+
// tolerant mode.
|
|
369
|
+
router.get("/routing", (_req, res) => {
|
|
370
|
+
const diagnostics = RoutingDiagnostics.getInstance();
|
|
371
|
+
res.json({
|
|
372
|
+
diagnostics: diagnostics.list(),
|
|
373
|
+
count: diagnostics.count(),
|
|
374
|
+
now: Date.now(),
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
// #103 follow-up — operator-facing escape hatch for the
|
|
378
|
+
// first-record-wins semantic. When the captured body is wrong / stale
|
|
379
|
+
// / contains a one-off payload, deleting the sample lets the next
|
|
380
|
+
// successful run re-record (only if the workflow's HTTP trigger has
|
|
381
|
+
// `recordSample: true`). Studio's workflow detail page wires a
|
|
382
|
+
// "Re-record sample" button to this endpoint. Returns `{ deleted:
|
|
383
|
+
// true }` on success, `404` when no sample exists for the workflow.
|
|
384
|
+
// JSON-rather-than-204 keeps `fetchJson()` on the client side simple
|
|
385
|
+
// and mirrors the saved-filters DELETE shape.
|
|
386
|
+
router.delete("/workflows/:name/sample", (req, res) => {
|
|
387
|
+
const { name } = req.params;
|
|
388
|
+
const removed = t.deleteWorkflowSample(name);
|
|
389
|
+
if (!removed) {
|
|
390
|
+
res.status(404).json({ error: `No recorded sample for workflow '${name}'` });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
res.json({ deleted: true });
|
|
394
|
+
});
|
|
78
395
|
router.get("/workflows/:name/runs", (req, res) => {
|
|
79
396
|
const { name } = req.params;
|
|
80
397
|
const status = req.query.status;
|
|
@@ -489,6 +806,21 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
489
806
|
const workflow = req.query.workflow;
|
|
490
807
|
const status = req.query.status;
|
|
491
808
|
const tags = req.query.tags ? req.query.tags.split(",").map((t) => t.trim()) : undefined;
|
|
809
|
+
// F2 (v0.5) — `metadata.<key>[__op]=<value>` query params parsed
|
|
810
|
+
// into a `MetadataFilter[]` for the RunQuery filter. Multiple
|
|
811
|
+
// pairs combine with AND semantics.
|
|
812
|
+
//
|
|
813
|
+
// Examples:
|
|
814
|
+
// metadata.tier=premium → {key: "tier", op: "eq", value: "premium"}
|
|
815
|
+
// metadata.tier__ne=free → {key: "tier", op: "ne", value: "free"}
|
|
816
|
+
// metadata.count__gt=10 → {key: "count", op: "gt", value: "10"}
|
|
817
|
+
// metadata.region__in=us,eu → {key: "region", op: "in", value: ["us","eu"]}
|
|
818
|
+
// metadata.name__like=test% → {key: "name", op: "like", value: "test%"}
|
|
819
|
+
//
|
|
820
|
+
// Keys are restricted by the SqliteRunStore implementation
|
|
821
|
+
// (`/^[a-zA-Z0-9_-]+$/`) for JSON path safety; non-matching keys
|
|
822
|
+
// or unknown operators silently drop.
|
|
823
|
+
const metadata = parseMetadataFiltersFromQuery(req.query);
|
|
492
824
|
const limit = Number.parseInt(req.query.limit || "50", 10);
|
|
493
825
|
const offset = Number.parseInt(req.query.offset || "0", 10);
|
|
494
826
|
const sort = req.query.sort || "desc";
|
|
@@ -520,6 +852,7 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
520
852
|
workflow,
|
|
521
853
|
status: status,
|
|
522
854
|
tags,
|
|
855
|
+
metadata,
|
|
523
856
|
limit: needsPostFilter ? Math.max(limit, 1000) : limit,
|
|
524
857
|
offset: needsPostFilter ? 0 : offset,
|
|
525
858
|
sort,
|
|
@@ -570,6 +903,21 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
570
903
|
const events = t.getEvents(runId, since);
|
|
571
904
|
res.json(events);
|
|
572
905
|
});
|
|
906
|
+
/**
|
|
907
|
+
* Tier 2 · sub-workflow lineage. Returns the runs that were started
|
|
908
|
+
* by `subworkflow:` steps inside the given parent run. Studio renders
|
|
909
|
+
* these as a "Sub-runs" list on the parent's run detail page.
|
|
910
|
+
*/
|
|
911
|
+
router.get("/runs/:runId/subruns", (req, res) => {
|
|
912
|
+
const { runId } = req.params;
|
|
913
|
+
const run = t.getRun(runId);
|
|
914
|
+
if (!run) {
|
|
915
|
+
res.status(404).json({ error: `Run '${runId}' not found` });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const subruns = t.getRunsByParent(runId);
|
|
919
|
+
res.json(subruns);
|
|
920
|
+
});
|
|
573
921
|
router.delete("/runs", (_req, res) => {
|
|
574
922
|
const deleted = t.clearAll();
|
|
575
923
|
res.json({ deleted });
|
|
@@ -602,9 +950,18 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
602
950
|
const overrides = (req.body || {});
|
|
603
951
|
const finalMethod = (overrides.method || method).toUpperCase();
|
|
604
952
|
const finalUrl = overrides.path ? `${protocol}://${host}${overrides.path}` : url;
|
|
953
|
+
// Security review FW-2 — strip sensitive headers from overrides
|
|
954
|
+
// BEFORE merging, then layer the framework-controlled headers
|
|
955
|
+
// LAST so an attacker can't replace `X-Blok-Replay-Of`.
|
|
956
|
+
const safeOverrideHeaders = filterReplayHeaders(overrides.headers);
|
|
605
957
|
const customHeaders = {
|
|
606
958
|
"Content-Type": "application/json",
|
|
607
|
-
...
|
|
959
|
+
...safeOverrideHeaders,
|
|
960
|
+
// Tier 1 · replay lineage. TriggerBase reads this header and threads
|
|
961
|
+
// it into `tracker.startRun({ replayOf })`, which persists onto the
|
|
962
|
+
// new run's WorkflowRun.replayOf field. Studio renders a
|
|
963
|
+
// "Replay of #..." breadcrumb that links back to the source run.
|
|
964
|
+
"X-Blok-Replay-Of": runId,
|
|
608
965
|
};
|
|
609
966
|
const body = overrides.body !== undefined ? JSON.stringify(overrides.body) : undefined;
|
|
610
967
|
// Listen for the next RUN_STARTED event matching this workflow
|
|
@@ -624,6 +981,10 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
624
981
|
newRunId: event.runId,
|
|
625
982
|
originalRunId: runId,
|
|
626
983
|
workflowName: run.workflowName,
|
|
984
|
+
// Tier 1 · explicit lineage in the API response so Studio
|
|
985
|
+
// doesn't have to fetch the new run separately to confirm
|
|
986
|
+
// the replay relationship.
|
|
987
|
+
replayOf: runId,
|
|
627
988
|
});
|
|
628
989
|
};
|
|
629
990
|
t.on("RUN_STARTED", onRunStarted);
|
|
@@ -656,6 +1017,200 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
656
1017
|
// Cleanup if client disconnects
|
|
657
1018
|
req.on("close", cleanup);
|
|
658
1019
|
});
|
|
1020
|
+
// === Concurrency observability (Tier 2 follow-up) ===
|
|
1021
|
+
/**
|
|
1022
|
+
* Concurrency backend health probe. Returns the configured backend
|
|
1023
|
+
* (`"in-process"` when none) and basic state. Useful for k8s-style
|
|
1024
|
+
* health checks AND Studio's "Backend status" tile.
|
|
1025
|
+
*
|
|
1026
|
+
* GET /__blok/concurrency/health
|
|
1027
|
+
*/
|
|
1028
|
+
router.get("/concurrency/health", (_req, res) => {
|
|
1029
|
+
const backend = t.getConcurrencyBackend();
|
|
1030
|
+
res.json({
|
|
1031
|
+
backend: backend?.name ?? "in-process",
|
|
1032
|
+
disabled: process.env.BLOK_CONCURRENCY_DISABLED === "1",
|
|
1033
|
+
leaseMs: process.env.BLOK_CONCURRENCY_LEASE_MS ? Number(process.env.BLOK_CONCURRENCY_LEASE_MS) : 60 * 60 * 1000,
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
/**
|
|
1037
|
+
* Snapshot of currently in-flight concurrency slots, grouped by
|
|
1038
|
+
* (workflowName, concurrencyKey) bucket. Powers Studio's per-key
|
|
1039
|
+
* in-flight tile.
|
|
1040
|
+
*
|
|
1041
|
+
* GET /__blok/concurrency/state
|
|
1042
|
+
*/
|
|
1043
|
+
router.get("/concurrency/state", (_req, res) => {
|
|
1044
|
+
const buckets = t.getStore().getConcurrencySnapshot(Date.now());
|
|
1045
|
+
const totalLeases = buckets.reduce((sum, b) => sum + b.leases.length, 0);
|
|
1046
|
+
res.json({
|
|
1047
|
+
totalBuckets: buckets.length,
|
|
1048
|
+
totalLeases,
|
|
1049
|
+
buckets: buckets.map((b) => ({
|
|
1050
|
+
workflowName: b.workflowName,
|
|
1051
|
+
concurrencyKey: b.concurrencyKey,
|
|
1052
|
+
inFlight: b.leases.length,
|
|
1053
|
+
leases: b.leases,
|
|
1054
|
+
})),
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
/**
|
|
1058
|
+
* List pending scheduled dispatches — rows from `scheduled_dispatches`
|
|
1059
|
+
* that haven't fired yet (delayed / queued / debounced). Powers
|
|
1060
|
+
* Studio's "Scheduled runs" view (E1) so operators can see + cancel
|
|
1061
|
+
* inbound dispatches BEFORE they execute.
|
|
1062
|
+
*
|
|
1063
|
+
* Already-fired runs are pruned from this table the moment
|
|
1064
|
+
* `dispatchDeferred` re-enters; expired runs are pruned by the
|
|
1065
|
+
* Janitor sweep. To see those, use `/__blok/runs?status=expired` /
|
|
1066
|
+
* `?status=completed` / etc. against `workflow_runs`.
|
|
1067
|
+
*
|
|
1068
|
+
* GET /__blok/scheduled
|
|
1069
|
+
*
|
|
1070
|
+
* Query params:
|
|
1071
|
+
* - `status` — comma-separated list of `delayed`/`queued`/`debounced`.
|
|
1072
|
+
* When omitted, returns all three.
|
|
1073
|
+
* - `workflowName` — exact-match filter.
|
|
1074
|
+
* - `limit` — pagination cap (default 100, max 500).
|
|
1075
|
+
* - `offset` — pagination offset (default 0).
|
|
1076
|
+
*
|
|
1077
|
+
* Returns:
|
|
1078
|
+
* `{ rows: ScheduledDispatchRow[], total: number, now: number }`
|
|
1079
|
+
* `now` is the server-side `Date.now()` snapshot so the client can
|
|
1080
|
+
* render accurate "fires in 27s" countdowns without clock skew.
|
|
1081
|
+
*/
|
|
1082
|
+
router.get("/scheduled", (req, res) => {
|
|
1083
|
+
const query = (req.query ?? {});
|
|
1084
|
+
const validStatuses = new Set(["delayed", "queued", "debounced"]);
|
|
1085
|
+
const requestedStatuses = (query.status ?? "")
|
|
1086
|
+
.split(",")
|
|
1087
|
+
.map((s) => s.trim())
|
|
1088
|
+
.filter((s) => s.length > 0 && validStatuses.has(s));
|
|
1089
|
+
const statusesToReturn = requestedStatuses.length > 0 ? requestedStatuses : ["delayed", "queued", "debounced"];
|
|
1090
|
+
// Pull each requested status separately — the underlying
|
|
1091
|
+
// `getScheduledDispatches({status})` only accepts a single status
|
|
1092
|
+
// string. Concatenate + sort by scheduledAt ASC so the soonest
|
|
1093
|
+
// next-fire is at the top of the table.
|
|
1094
|
+
const store = t.getStore();
|
|
1095
|
+
const allRows = statusesToReturn.flatMap((status) => store.getScheduledDispatches({ status: status }));
|
|
1096
|
+
const workflowFilter = typeof query.workflowName === "string" ? query.workflowName : undefined;
|
|
1097
|
+
const filtered = workflowFilter ? allRows.filter((r) => r.workflowName === workflowFilter) : allRows;
|
|
1098
|
+
filtered.sort((a, b) => a.scheduledAt - b.scheduledAt);
|
|
1099
|
+
// Pagination — the underlying store does full scans today; cap at
|
|
1100
|
+
// 500 so a runaway query can't pin the event loop.
|
|
1101
|
+
const limit = clampInt(query.limit, 1, 500, 100);
|
|
1102
|
+
const offset = clampInt(query.offset, 0, Number.MAX_SAFE_INTEGER, 0);
|
|
1103
|
+
const page = filtered.slice(offset, offset + limit);
|
|
1104
|
+
// Sensitive headers are already stripped at persist time
|
|
1105
|
+
// (see `extractDispatchPayload` in HttpTrigger). Re-strip on read
|
|
1106
|
+
// too as a belt-and-braces measure — operators should never see
|
|
1107
|
+
// `authorization` / `cookie` / `x-api-key` in the Studio UI.
|
|
1108
|
+
const sanitized = page.map((row) => ({ ...row, payload: sanitizeDispatchPayload(row.payload) }));
|
|
1109
|
+
res.json({
|
|
1110
|
+
rows: sanitized,
|
|
1111
|
+
total: filtered.length,
|
|
1112
|
+
now: Date.now(),
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
// === Cancellation (Tier 2 polish) ===
|
|
1116
|
+
/**
|
|
1117
|
+
* Cancel a pending (delayed/debounced/queued) run before it executes.
|
|
1118
|
+
*
|
|
1119
|
+
* `POST /__blok/runs/:runId/cancel`
|
|
1120
|
+
*
|
|
1121
|
+
* Returns:
|
|
1122
|
+
* - `200 { cancelled: true, runId, previousStatus, newStatus: "cancelled" }` on success
|
|
1123
|
+
* - `400 { error }` when the run isn't in a cancellable state
|
|
1124
|
+
* (running/completed/failed/throttled/expired/crashed/timedOut/cancelled)
|
|
1125
|
+
* - `404 { error }` when the runId doesn't exist
|
|
1126
|
+
*
|
|
1127
|
+
* Cancels the underlying scheduler entry (`DeferredRunScheduler` for
|
|
1128
|
+
* delayed/queued runs; `DebounceCoordinator` for debounced trailing-mode
|
|
1129
|
+
* runs) AND flips the run's status to `"cancelled"` via
|
|
1130
|
+
* `tracker.cancelRun(runId)`. Both scheduler `.cancel()` methods are
|
|
1131
|
+
* idempotent so calling them on a runId that doesn't have a pending
|
|
1132
|
+
* timer is a safe no-op.
|
|
1133
|
+
*/
|
|
1134
|
+
router.post("/runs/:runId/cancel", (req, res) => {
|
|
1135
|
+
const { runId } = req.params;
|
|
1136
|
+
const run = t.getRun(runId);
|
|
1137
|
+
if (!run) {
|
|
1138
|
+
res.status(404).json({ error: `Run '${runId}' not found` });
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
// Tier 2 follow-up · "running" added so cooperative AbortSignal
|
|
1142
|
+
// cancellation can flip in-flight runs to `cancelled` via
|
|
1143
|
+
// `tracker.abortRunningRun(runId)`. Other terminal states
|
|
1144
|
+
// (completed/failed/throttled/expired/crashed/timedOut) remain
|
|
1145
|
+
// non-cancellable.
|
|
1146
|
+
const cancellable = ["delayed", "debounced", "queued", "running"];
|
|
1147
|
+
if (!cancellable.includes(run.status)) {
|
|
1148
|
+
res.status(400).json({
|
|
1149
|
+
error: `Cannot cancel run in '${run.status}' state. Only runs in 'delayed', 'debounced', 'queued', or 'running' state can be cancelled.`,
|
|
1150
|
+
runId,
|
|
1151
|
+
status: run.status,
|
|
1152
|
+
});
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
// Capture previousStatus BEFORE cancelRun mutates the run record.
|
|
1156
|
+
const previousStatus = run.status;
|
|
1157
|
+
// Tier 2 follow-up · running runs use cooperative AbortSignal.
|
|
1158
|
+
// `abortRunningRun` fires the controller AND flips status via
|
|
1159
|
+
// cancelRun in one atomic-feeling call. Returns 200 — the
|
|
1160
|
+
// in-flight step's between-step check will throw shortly.
|
|
1161
|
+
if (run.status === "running") {
|
|
1162
|
+
const aborted = t.abortRunningRun(runId);
|
|
1163
|
+
if (!aborted) {
|
|
1164
|
+
// No registered controller — likely a stale state where
|
|
1165
|
+
// the run is mid-finalization. Still return success since
|
|
1166
|
+
// the run is on its way to terminal anyway.
|
|
1167
|
+
res.json({
|
|
1168
|
+
cancelled: true,
|
|
1169
|
+
runId,
|
|
1170
|
+
previousStatus,
|
|
1171
|
+
newStatus: "cancelled",
|
|
1172
|
+
note: "No active AbortController; run will reach terminal state naturally.",
|
|
1173
|
+
});
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
res.json({
|
|
1177
|
+
cancelled: true,
|
|
1178
|
+
runId,
|
|
1179
|
+
previousStatus,
|
|
1180
|
+
newStatus: "cancelled",
|
|
1181
|
+
note: "Cancellation initiated via AbortSignal; in-flight step will abort cooperatively.",
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
// Best-effort scheduler cleanup (both methods are idempotent).
|
|
1186
|
+
DeferredRunScheduler.getInstance().cancel(runId);
|
|
1187
|
+
if (run.debounceKey) {
|
|
1188
|
+
// Tier C #1 — `cancel()` is now async because the coordinator may
|
|
1189
|
+
// route through a cross-process backend. Fire-and-forget: the
|
|
1190
|
+
// HTTP response shouldn't block on broker cleanup, and the
|
|
1191
|
+
// run-status flip below is the source of truth for the caller.
|
|
1192
|
+
void DebounceCoordinator.getInstance()
|
|
1193
|
+
.cancel(run.workflowName, run.debounceKey)
|
|
1194
|
+
.catch((err) => {
|
|
1195
|
+
console.warn(`[blok][scheduling] debounce cancel failed for ${run.workflowName}:${run.debounceKey}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
const cancelled = t.cancelRun(runId);
|
|
1199
|
+
if (!cancelled) {
|
|
1200
|
+
// Race: status changed between our check and the call.
|
|
1201
|
+
res.status(409).json({
|
|
1202
|
+
error: `Could not cancel run '${runId}'. It may have just transitioned to a non-cancellable state.`,
|
|
1203
|
+
runId,
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
res.json({
|
|
1208
|
+
cancelled: true,
|
|
1209
|
+
runId,
|
|
1210
|
+
previousStatus,
|
|
1211
|
+
newStatus: "cancelled",
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
659
1214
|
// === AI Error Explanation ===
|
|
660
1215
|
/**
|
|
661
1216
|
* Explain a run or node error using an LLM.
|
|
@@ -841,6 +1396,57 @@ export function registerTraceRoutes(router, tracker) {
|
|
|
841
1396
|
t.saveDashboard(copy);
|
|
842
1397
|
res.status(201).json(copy);
|
|
843
1398
|
});
|
|
1399
|
+
// === Saved filters (E2) ===
|
|
1400
|
+
/**
|
|
1401
|
+
* List every saved filter. Newest-updated first so the dropdown
|
|
1402
|
+
* surfaces recently-edited presets at the top.
|
|
1403
|
+
* GET /__blok/saved-filters
|
|
1404
|
+
*/
|
|
1405
|
+
router.get("/saved-filters", (_req, res) => {
|
|
1406
|
+
res.json({ filters: t.listSavedFilters() });
|
|
1407
|
+
});
|
|
1408
|
+
/**
|
|
1409
|
+
* Upsert a saved filter. `name` is the unique key — re-posting with
|
|
1410
|
+
* the same name overwrites the existing entry (preserves `id` +
|
|
1411
|
+
* `createdAt`). Studio uses this to replace the localStorage
|
|
1412
|
+
* `saveFilter()` call.
|
|
1413
|
+
* POST /__blok/saved-filters
|
|
1414
|
+
* Body: { name, status, tagsInput, metadataInput }
|
|
1415
|
+
*/
|
|
1416
|
+
router.post("/saved-filters", (req, res) => {
|
|
1417
|
+
const body = (req.body || {});
|
|
1418
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
1419
|
+
if (name.length === 0) {
|
|
1420
|
+
res.status(400).json({ error: "Saved-filter `name` is required" });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
const now = Date.now();
|
|
1424
|
+
const persisted = t.upsertSavedFilter({
|
|
1425
|
+
id: `sf_${now.toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1426
|
+
name,
|
|
1427
|
+
status: typeof body.status === "string" ? body.status : "",
|
|
1428
|
+
tagsInput: typeof body.tagsInput === "string" ? body.tagsInput : "",
|
|
1429
|
+
metadataInput: typeof body.metadataInput === "string" ? body.metadataInput : "",
|
|
1430
|
+
createdAt: now,
|
|
1431
|
+
updatedAt: now,
|
|
1432
|
+
});
|
|
1433
|
+
res.status(201).json(persisted);
|
|
1434
|
+
});
|
|
1435
|
+
/**
|
|
1436
|
+
* Delete a saved filter by name. Returns `404` when the row didn't
|
|
1437
|
+
* exist (so the Studio mutation can disambiguate). Uses the name
|
|
1438
|
+
* (not the id) so the Studio component — which knows the name from
|
|
1439
|
+
* the dropdown — doesn't need a round-trip to learn the id first.
|
|
1440
|
+
* DELETE /__blok/saved-filters/:name
|
|
1441
|
+
*/
|
|
1442
|
+
router.delete("/saved-filters/:name", (req, res) => {
|
|
1443
|
+
const removed = t.deleteSavedFilter(req.params.name);
|
|
1444
|
+
if (!removed) {
|
|
1445
|
+
res.status(404).json({ error: `Saved filter '${req.params.name}' not found` });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
res.json({ deleted: true });
|
|
1449
|
+
});
|
|
844
1450
|
// === SSE Endpoints ===
|
|
845
1451
|
/**
|
|
846
1452
|
* SSE stream for a specific run.
|