@dudousxd/nestjs-durable-dashboard 0.24.0 → 0.25.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/server/index.cjs +68 -51
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +31 -14
- package/dist/server/index.d.ts +31 -14
- package/dist/server/index.js +69 -52
- package/dist/server/index.js.map +1 -1
- package/package.json +2 -2
package/dist/server/index.cjs
CHANGED
|
@@ -36,6 +36,11 @@ module.exports = __toCommonJS(index_exports);
|
|
|
36
36
|
var import_nestjs_durable_core = require("@dudousxd/nestjs-durable-core");
|
|
37
37
|
var import_common = require("@nestjs/common");
|
|
38
38
|
var import_rxjs = require("rxjs");
|
|
39
|
+
|
|
40
|
+
// src/server/tokens.ts
|
|
41
|
+
var RUN_GATEWAY = Symbol.for("nestjs-durable:run-gateway");
|
|
42
|
+
|
|
43
|
+
// src/server/dashboard.service.ts
|
|
39
44
|
function _ts_decorate(decorators, target, key, desc) {
|
|
40
45
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
41
46
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -57,33 +62,52 @@ var DashboardService = class {
|
|
|
57
62
|
static {
|
|
58
63
|
__name(this, "DashboardService");
|
|
59
64
|
}
|
|
65
|
+
gateway;
|
|
60
66
|
store;
|
|
61
67
|
engine;
|
|
62
|
-
/** Prometheus counters accumulated from engine events since boot (per process)
|
|
68
|
+
/** Prometheus counters accumulated from engine events since boot (per process); undefined on a
|
|
69
|
+
* store-less tenant (no engine to accumulate from). */
|
|
63
70
|
metricsCollector;
|
|
64
|
-
constructor(store, engine) {
|
|
71
|
+
constructor(gateway, store, engine) {
|
|
72
|
+
this.gateway = gateway;
|
|
65
73
|
this.store = store;
|
|
66
74
|
this.engine = engine;
|
|
67
|
-
this.metricsCollector = (0, import_nestjs_durable_core.collectMetrics)(this.engine);
|
|
75
|
+
this.metricsCollector = this.store && this.engine ? (0, import_nestjs_durable_core.collectMetrics)(this.engine) : void 0;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The operator-only ops need the FULL engine + canonical store, which only the control plane has.
|
|
79
|
+
* On a tenant the `WorkflowEngine` token is a store-less start client that implements none of these
|
|
80
|
+
* methods, so presence of the canonical store is the reliable discriminator — gate on it (never
|
|
81
|
+
* on engine presence, which is truthy on a tenant too).
|
|
82
|
+
*/
|
|
83
|
+
controlPlane() {
|
|
84
|
+
if (!this.store || !this.engine) {
|
|
85
|
+
throw new Error("This durable dashboard operation requires the control plane (not available on a tenant deployment).");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
store: this.store,
|
|
89
|
+
engine: this.engine
|
|
90
|
+
};
|
|
68
91
|
}
|
|
69
92
|
/**
|
|
70
93
|
* Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,
|
|
71
94
|
* per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`
|
|
72
95
|
* (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ
|
|
73
|
-
* size). Capped per status so the scrape can't load an unbounded result set.
|
|
96
|
+
* size). Capped per status so the scrape can't load an unbounded result set. Control-plane-only.
|
|
74
97
|
*/
|
|
75
98
|
async metrics() {
|
|
99
|
+
const { store, engine } = this.controlPlane();
|
|
76
100
|
const cap = 1e4;
|
|
77
101
|
const [pending, running, dead] = await Promise.all([
|
|
78
|
-
|
|
102
|
+
store.listRuns({
|
|
79
103
|
status: "pending",
|
|
80
104
|
limit: cap
|
|
81
105
|
}),
|
|
82
|
-
|
|
106
|
+
store.listRuns({
|
|
83
107
|
status: "running",
|
|
84
108
|
limit: cap
|
|
85
109
|
}),
|
|
86
|
-
|
|
110
|
+
store.listRuns({
|
|
87
111
|
status: "dead",
|
|
88
112
|
limit: cap
|
|
89
113
|
})
|
|
@@ -96,7 +120,7 @@ var DashboardService = class {
|
|
|
96
120
|
"# TYPE durable_dead_runs gauge",
|
|
97
121
|
`durable_dead_runs ${dead.length}`
|
|
98
122
|
];
|
|
99
|
-
const health = await
|
|
123
|
+
const health = await engine.workerHealth();
|
|
100
124
|
if (health.length > 0) {
|
|
101
125
|
gauges.push("# TYPE durable_group_queue_depth gauge");
|
|
102
126
|
for (const h of health) {
|
|
@@ -107,40 +131,30 @@ var DashboardService = class {
|
|
|
107
131
|
gauges.push(`durable_group_live_workers{group="${h.group}"} ${h.liveWorkers.length}`);
|
|
108
132
|
}
|
|
109
133
|
}
|
|
110
|
-
return `${this.metricsCollector
|
|
134
|
+
return `${this.metricsCollector?.prometheus() ?? ""}${gauges.join("\n")}
|
|
111
135
|
`;
|
|
112
136
|
}
|
|
113
137
|
listRuns(query) {
|
|
114
|
-
return this.
|
|
138
|
+
return this.gateway.listRuns(query);
|
|
115
139
|
}
|
|
116
140
|
/** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The
|
|
117
|
-
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. */
|
|
118
|
-
workerHealth() {
|
|
119
|
-
return this.engine.workerHealth();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (!run) return null;
|
|
124
|
-
const [timeline, children] = await Promise.all([
|
|
125
|
-
this.store.listCheckpoints(runId),
|
|
126
|
-
this.engine.getRunChildren(runId)
|
|
127
|
-
]);
|
|
128
|
-
return {
|
|
129
|
-
run,
|
|
130
|
-
timeline,
|
|
131
|
-
children
|
|
132
|
-
};
|
|
141
|
+
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. Control-plane-only. */
|
|
142
|
+
async workerHealth() {
|
|
143
|
+
return this.controlPlane().engine.workerHealth();
|
|
144
|
+
}
|
|
145
|
+
getRunDetail(runId) {
|
|
146
|
+
return this.gateway.getRunDetail(runId);
|
|
133
147
|
}
|
|
134
148
|
/** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */
|
|
135
149
|
retryWithInput(runId, input) {
|
|
136
|
-
return this.
|
|
150
|
+
return this.gateway.retryWithInput(runId, input);
|
|
137
151
|
}
|
|
138
152
|
/** Re-run a failed/incomplete run; completed steps replay from their checkpoints. */
|
|
139
153
|
retry(runId) {
|
|
140
|
-
return this.
|
|
154
|
+
return this.gateway.retry(runId);
|
|
141
155
|
}
|
|
142
156
|
cancel(runId, opts) {
|
|
143
|
-
return this.
|
|
157
|
+
return this.gateway.cancel(runId, opts);
|
|
144
158
|
}
|
|
145
159
|
/**
|
|
146
160
|
* Apply `retry` or `cancel` to every run matching a filter (status / tag / workflow) — e.g. "retry
|
|
@@ -148,15 +162,15 @@ var DashboardService = class {
|
|
|
148
162
|
* (already terminal) are skipped. Returns how many matched and how many the action applied to.
|
|
149
163
|
*/
|
|
150
164
|
async bulk(action, filter, opts) {
|
|
151
|
-
const runs = await this.
|
|
165
|
+
const runs = await this.gateway.listRuns({
|
|
152
166
|
...filter,
|
|
153
167
|
limit: 500
|
|
154
168
|
});
|
|
155
169
|
let applied = 0;
|
|
156
170
|
for (const r of runs) {
|
|
157
171
|
try {
|
|
158
|
-
if (action === "retry") await this.
|
|
159
|
-
else await this.
|
|
172
|
+
if (action === "retry") await this.gateway.retry(r.id);
|
|
173
|
+
else await this.gateway.cancel(r.id, opts);
|
|
160
174
|
applied += 1;
|
|
161
175
|
} catch {
|
|
162
176
|
}
|
|
@@ -168,45 +182,48 @@ var DashboardService = class {
|
|
|
168
182
|
}
|
|
169
183
|
/** Resume a run paused at a `ctx.breakpoint` (the "continue" button). */
|
|
170
184
|
continue(runId) {
|
|
171
|
-
return this.
|
|
185
|
+
return this.gateway.continue(runId);
|
|
172
186
|
}
|
|
173
187
|
/**
|
|
174
188
|
* Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the
|
|
175
189
|
* waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a
|
|
176
|
-
* stale/duplicate callback) — a safe no-op the controller maps to 404.
|
|
190
|
+
* stale/duplicate callback) — a safe no-op the controller maps to 404. Control-plane-only.
|
|
177
191
|
*/
|
|
178
|
-
deliverWebhook(token, body) {
|
|
179
|
-
return this.engine.signal(token, body);
|
|
192
|
+
async deliverWebhook(token, body) {
|
|
193
|
+
return this.controlPlane().engine.signal(token, body);
|
|
180
194
|
}
|
|
181
|
-
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). */
|
|
182
|
-
getEvent(runId, key) {
|
|
183
|
-
return this.engine.getEvent(runId, key);
|
|
195
|
+
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). Control-plane-only. */
|
|
196
|
+
async getEvent(runId, key) {
|
|
197
|
+
return this.controlPlane().engine.getEvent(runId, key);
|
|
184
198
|
}
|
|
185
|
-
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). */
|
|
186
|
-
update(runId, name, arg) {
|
|
187
|
-
return this.engine.update(runId, name, arg);
|
|
199
|
+
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). Control-plane-only. */
|
|
200
|
+
async update(runId, name, arg) {
|
|
201
|
+
return this.controlPlane().engine.update(runId, name, arg);
|
|
188
202
|
}
|
|
189
203
|
/**
|
|
190
|
-
* Live stream of a run's lifecycle events for SSE. Backed by `
|
|
191
|
-
*
|
|
192
|
-
*
|
|
204
|
+
* Live stream of a run's lifecycle events for SSE. Backed by `gateway.subscribe`, which — on the
|
|
205
|
+
* control plane — receives events from EVERY instance (so a dashboard-only pod tails a run
|
|
206
|
+
* executing on a worker pod), and on a tenant round-trips over the transport. Either way the
|
|
207
|
+
* gateway already filters to `runId`; the dashboard just wires the events through.
|
|
193
208
|
*/
|
|
194
209
|
streamRun(runId) {
|
|
195
210
|
return new import_rxjs.Observable((subscriber) => {
|
|
196
|
-
const off = this.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
});
|
|
200
|
-
});
|
|
211
|
+
const off = this.gateway.subscribe(runId, (event) => subscriber.next({
|
|
212
|
+
data: event
|
|
213
|
+
}));
|
|
201
214
|
return () => off();
|
|
202
215
|
});
|
|
203
216
|
}
|
|
204
217
|
};
|
|
205
218
|
DashboardService = _ts_decorate([
|
|
206
219
|
(0, import_common.Injectable)(),
|
|
207
|
-
_ts_param(0, (0, import_common.Inject)(
|
|
220
|
+
_ts_param(0, (0, import_common.Inject)(RUN_GATEWAY)),
|
|
221
|
+
_ts_param(1, (0, import_common.Optional)()),
|
|
222
|
+
_ts_param(1, (0, import_common.Inject)(import_nestjs_durable_core.STATE_STORE_CANONICAL)),
|
|
223
|
+
_ts_param(2, (0, import_common.Optional)()),
|
|
208
224
|
_ts_metadata("design:type", Function),
|
|
209
225
|
_ts_metadata("design:paramtypes", [
|
|
226
|
+
typeof RunGateway === "undefined" ? Object : RunGateway,
|
|
210
227
|
typeof StateStore === "undefined" ? Object : StateStore,
|
|
211
228
|
typeof import_nestjs_durable_core.WorkflowEngine === "undefined" ? Object : import_nestjs_durable_core.WorkflowEngine
|
|
212
229
|
])
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/index.ts","../../src/server/dashboard.service.ts","../../src/server/durable-api.controller.ts","../../src/server/attr-filter.ts","../../src/server/durable-dashboard.module.ts","../../src/server/durable-ui.controller.ts"],"sourcesContent":["export * from './dashboard.service.js';\nexport * from './durable-api.controller.js';\nexport * from './durable-dashboard.module.js';\nexport * from './durable-ui.controller.js';\n","import {\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type StepCheckpoint,\n type UpdateResult,\n WorkflowEngine,\n type WorkflowRun,\n collectMetrics,\n} from '@dudousxd/nestjs-durable-core';\nimport { Inject, Injectable } from '@nestjs/common';\nimport { Observable } from 'rxjs';\n\nexport interface RunDetail {\n run: WorkflowRun;\n /** Steps in execution order — the end-to-end timeline (local + remote). */\n timeline: StepCheckpoint[];\n /** Ids of runs this run spawned (ctx.child / ctx.startChild) — the parent→children tree. */\n children: string[];\n}\n\n/** Read-model and actions backing the control-plane UI. */\n@Injectable()\nexport class DashboardService {\n /** Prometheus counters accumulated from engine events since boot (per process). */\n private readonly metricsCollector: MetricsCollector;\n\n constructor(\n @Inject(STATE_STORE_CANONICAL) private readonly store: StateStore,\n private readonly engine: WorkflowEngine,\n ) {\n this.metricsCollector = collectMetrics(this.engine);\n }\n\n /**\n * Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,\n * per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`\n * (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ\n * size). Capped per status so the scrape can't load an unbounded result set.\n */\n async metrics(): Promise<string> {\n const cap = 10_000;\n const [pending, running, dead] = await Promise.all([\n this.store.listRuns({ status: 'pending', limit: cap }),\n this.store.listRuns({ status: 'running', limit: cap }),\n this.store.listRuns({ status: 'dead', limit: cap }),\n ]);\n const gauges = [\n '# TYPE durable_pending_runs gauge',\n `durable_pending_runs ${pending.length}`,\n '# TYPE durable_running_runs gauge',\n `durable_running_runs ${running.length}`,\n '# TYPE durable_dead_runs gauge',\n `durable_dead_runs ${dead.length}`,\n ];\n // Per-group worker health: backlog vs. live workers. `depth>0 && live==0` is the alert (work\n // piling up with no consumer) — expressible as a Prometheus rule on these two series.\n const health = await this.engine.workerHealth();\n if (health.length > 0) {\n gauges.push('# TYPE durable_group_queue_depth gauge');\n for (const h of health) {\n gauges.push(`durable_group_queue_depth{group=\"${h.group}\"} ${h.depth}`);\n }\n gauges.push('# TYPE durable_group_live_workers gauge');\n for (const h of health) {\n gauges.push(`durable_group_live_workers{group=\"${h.group}\"} ${h.liveWorkers.length}`);\n }\n }\n return `${this.metricsCollector.prometheus()}${gauges.join('\\n')}\\n`;\n }\n\n listRuns(query: RunQuery): Promise<WorkflowRun[]> {\n return this.store.listRuns(query);\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The\n * alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. */\n workerHealth(): Promise<GroupHealth[]> {\n return this.engine.workerHealth();\n }\n\n async getRunDetail(runId: string): Promise<RunDetail | null> {\n const run = await this.store.getRun(runId);\n if (!run) return null;\n const [timeline, children] = await Promise.all([\n this.store.listCheckpoints(runId),\n this.engine.getRunChildren(runId), // canonical parent→children edge (shared with cancel cascade)\n ]);\n return { run, timeline, children };\n }\n\n /** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */\n retryWithInput(runId: string, input: unknown): Promise<{ runId: string } | null> {\n return this.engine.retryWithInput(runId, input);\n }\n\n /** Re-run a failed/incomplete run; completed steps replay from their checkpoints. */\n retry(runId: string): Promise<RunResult | null> {\n // Re-enqueue (dispatch model) instead of resuming inline, so the request can't hang on workflow\n // execution — a worker picks the run up and replays it.\n return this.engine.requeue(runId);\n }\n\n cancel(runId: string, opts?: { compensate?: boolean }): Promise<RunResult | null> {\n return this.engine.cancel(runId, opts);\n }\n\n /**\n * Apply `retry` or `cancel` to every run matching a filter (status / tag / workflow) — e.g. \"retry\n * every `dead` run tagged `type:mel`\". Capped at 500 runs per call; runs that can't be acted on\n * (already terminal) are skipped. Returns how many matched and how many the action applied to.\n */\n async bulk(\n action: 'retry' | 'cancel',\n filter: Pick<RunQuery, 'status' | 'tag' | 'workflow' | 'attributes'>,\n opts?: { compensate?: boolean },\n ): Promise<{ matched: number; applied: number }> {\n const runs = await this.store.listRuns({ ...filter, limit: 500 });\n let applied = 0;\n for (const r of runs) {\n try {\n if (action === 'retry') await this.engine.requeue(r.id);\n else await this.engine.cancel(r.id, opts);\n applied += 1;\n } catch {\n // Skip a run that can't take the action (e.g. already terminal) — keep going.\n }\n }\n return { matched: runs.length, applied };\n }\n\n /** Resume a run paused at a `ctx.breakpoint` (the \"continue\" button). */\n continue(runId: string): Promise<RunResult | null> {\n return this.engine.continue(runId);\n }\n\n /**\n * Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the\n * waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a\n * stale/duplicate callback) — a safe no-op the controller maps to 404.\n */\n deliverWebhook(token: string, body: unknown): Promise<RunResult | null> {\n return this.engine.signal(token, body);\n }\n\n /** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). */\n getEvent(runId: string, key: string): Promise<unknown> {\n return this.engine.getEvent(runId, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). */\n update(runId: string, name: string, arg: unknown): Promise<UpdateResult> {\n return this.engine.update(runId, name, arg);\n }\n\n /**\n * Live stream of a run's lifecycle events for SSE. Backed by `engine.subscribe`, which — when the\n * transport has a control plane — receives events from EVERY instance, so a dashboard-only pod\n * tails a run executing on a worker pod. Without a control plane it only sees same-process events.\n */\n streamRun(runId: string): Observable<{ data: EngineEvent }> {\n return new Observable<{ data: EngineEvent }>((subscriber) => {\n const off = this.engine.subscribe((event) => {\n if (event.runId === runId) subscriber.next({ data: event });\n });\n return () => off();\n });\n }\n}\n","import type { RunStatus } from '@dudousxd/nestjs-durable-core';\nimport {\n Body,\n Controller,\n Get,\n Header,\n NotFoundException,\n Param,\n Post,\n Query,\n Sse,\n} from '@nestjs/common';\nimport { parseAttrFilters } from './attr-filter.js';\nimport { DashboardService } from './dashboard.service.js';\n\n/** JSON API consumed by the control-plane SPA. Mounted at `apiBasePath` (set by RouterModule). */\n@Controller()\nexport class DurableApiController {\n constructor(private readonly dashboard: DashboardService) {}\n\n @Get('runs')\n runs(\n @Query('status') status?: RunStatus,\n @Query('workflow') workflow?: string,\n @Query('tag') tag?: string,\n @Query('attr') attr?: string | string[],\n ) {\n return this.dashboard.listRuns({ status, workflow, tag, attributes: parseAttrFilters(attr) });\n }\n\n /** Prometheus-text metrics (runs/steps by outcome, per-workflow counts) for a scrape. */\n @Get('metrics')\n @Header('Content-Type', 'text/plain; version=0.0.4')\n metrics() {\n return this.dashboard.metrics();\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. */\n @Get('workers')\n workers() {\n return this.dashboard.workerHealth();\n }\n\n @Get('runs/:id')\n async run(@Param('id') id: string) {\n const detail = await this.dashboard.getRunDetail(id);\n if (!detail) throw new NotFoundException(`run ${id} not found`);\n return detail;\n }\n\n /** Server-Sent Events stream of a run's live lifecycle events — the dashboard tails it instead\n * of polling. Cross-pod when the transport has a control plane (see DashboardService.streamRun). */\n @Sse('runs/:id/stream')\n stream(@Param('id') id: string) {\n return this.dashboard.streamRun(id);\n }\n\n @Post('runs/:id/retry')\n retry(@Param('id') id: string) {\n return this.dashboard.retry(id);\n }\n\n /** Fix-and-replay: re-run a dead/failed run with a corrected input (a fresh linked run). */\n @Post('runs/:id/retry-with-input')\n retryWithInput(@Param('id') id: string, @Body() body: { input: unknown }) {\n return this.dashboard.retryWithInput(id, body?.input);\n }\n\n /** Bulk retry/cancel every run matching a filter (status / tag / workflow). */\n @Post('bulk/:action')\n bulk(\n @Param('action') action: 'retry' | 'cancel',\n @Query('status') status?: RunStatus,\n @Query('tag') tag?: string,\n @Query('workflow') workflow?: string,\n @Query('attr') attr?: string | string[],\n @Query('compensate') compensate?: string,\n ) {\n return this.dashboard.bulk(\n action === 'cancel' ? 'cancel' : 'retry',\n { status, tag, workflow, attributes: parseAttrFilters(attr) },\n { compensate: compensate === 'true' },\n );\n }\n\n @Post('runs/:id/cancel')\n async cancel(@Param('id') id: string, @Query('compensate') compensate?: string) {\n const result = await this.dashboard.cancel(id, { compensate: compensate === 'true' });\n if (!result) throw new NotFoundException(`run ${id} not found`);\n return result;\n }\n\n @Post('runs/:id/continue')\n async continue(@Param('id') id: string) {\n const result = await this.dashboard.continue(id);\n if (!result) throw new NotFoundException(`run ${id} is not paused at a breakpoint`);\n return result;\n }\n\n /**\n * Public callback endpoint for `ctx.webhook()`: a third party POSTs here (the url handed to it),\n * and the body resumes the waiting run. NOTE: this is reachable by external systems — protect it\n * by treating the token as a secret (it embeds runId:seq) and/or fronting it with signature\n * verification in your own middleware.\n */\n @Post('webhooks/:token')\n async webhook(@Param('token') token: string, @Body() body: unknown) {\n const result = await this.dashboard.deliverWebhook(token, body);\n if (!result) throw new NotFoundException(`no run is waiting on webhook ${token}`);\n return result;\n }\n\n /** Live query: the latest value a run published for `key` via `ctx.setEvent` (no side effect). */\n @Get('runs/:id/events/:key')\n event(@Param('id') id: string, @Param('key') key: string) {\n return this.dashboard.getEvent(id, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the body is the update argument. */\n @Post('runs/:id/updates/:name')\n update(@Param('id') id: string, @Param('name') name: string, @Body() body: unknown) {\n return this.dashboard.update(id, name, body);\n }\n}\n","import type { AttributeFilter, AttributeOp } from '@dudousxd/nestjs-durable-core';\n\nconst ATTR_OPS = new Set<AttributeOp>(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);\n\n/** Coerce a query-string value: `true`/`false` → boolean, numeric → number, else the raw string. */\nfunction coerce(v: string): string | number | boolean {\n if (v === 'true') return true;\n if (v === 'false') return false;\n if (v !== '' && !Number.isNaN(Number(v))) return Number(v);\n return v;\n}\n\n/**\n * Parse `attr=key:op:value` query params (repeatable, ANDed) into {@link AttributeFilter}s — e.g.\n * `?attr=amount:gte:200&attr=tier:eq:pro`. `op` must be a known operator; a colon in the value is\n * preserved (only the first two colons delimit). Malformed entries are skipped.\n */\nexport function parseAttrFilters(attr?: string | string[]): AttributeFilter[] | undefined {\n if (!attr) return undefined;\n const raw = Array.isArray(attr) ? attr : [attr];\n const filters: AttributeFilter[] = [];\n for (const entry of raw) {\n const [key, op, ...rest] = entry.split(':');\n if (!key || !ATTR_OPS.has(op as AttributeOp) || rest.length === 0) continue;\n filters.push({ key, op: op as AttributeOp, value: coerce(rest.join(':')) });\n }\n return filters.length ? filters : undefined;\n}\n","import { type DynamicModule, Module } from '@nestjs/common';\nimport { RouterModule } from '@nestjs/core';\nimport { DashboardService } from './dashboard.service.js';\nimport { DurableApiController } from './durable-api.controller.js';\nimport {\n DASHBOARD_API_PATH,\n DASHBOARD_BASE_PATH,\n DurableUiController,\n} from './durable-ui.controller.js';\n\nexport interface DurableDashboardOptions {\n /**\n * Where the SPA (UI) is served. Default `/durable`. This is a page route — keep it out of an\n * `/api` prefix so it reads as a UI, not an endpoint.\n */\n basePath?: string;\n /**\n * Where the JSON API is mounted (what the SPA fetches). Default `<basePath>/api`. Set it under\n * your app's `/api` prefix — e.g. `/api/durable` — so the API inherits the app's auth/proxy rules\n * while the UI stays at `basePath`.\n */\n apiBasePath?: string;\n}\n\n/** Leading slash, no trailing slash. */\nfunction normalize(path: string): string {\n return `/${path.replace(/^\\/+|\\/+$/g, '')}`;\n}\n\n/** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */\n@Module({\n controllers: [DurableApiController],\n providers: [DashboardService],\n exports: [DashboardService],\n})\nexport class DurableApiModule {}\n\n/**\n * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`\n * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside\n * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.\n */\n@Module({})\nexport class DurableDashboardModule {\n static forRoot(options: DurableDashboardOptions = {}): DynamicModule {\n const basePath = normalize(options.basePath ?? '/durable');\n const apiBasePath = normalize(options.apiBasePath ?? `${basePath}/api`);\n return {\n module: DurableDashboardModule,\n imports: [\n DurableApiModule,\n RouterModule.register([\n { path: basePath, module: DurableDashboardModule }, // the UI controller below\n { path: apiBasePath, module: DurableApiModule },\n ]),\n ],\n controllers: [DurableUiController],\n providers: [\n { provide: DASHBOARD_BASE_PATH, useValue: basePath },\n { provide: DASHBOARD_API_PATH, useValue: apiBasePath },\n ],\n // Re-export the API module so its DashboardService reaches importers (e.g. flip's own controllers).\n exports: [DurableApiModule],\n };\n }\n}\n","import { existsSync, readFileSync } from 'node:fs';\nimport { basename, extname, join, resolve, sep } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport {\n Controller,\n Get,\n Header,\n Inject,\n NotFoundException,\n Param,\n StreamableFile,\n} from '@nestjs/common';\n\n/** DI token carrying the UI mount base (e.g. `/durable`). */\nexport const DASHBOARD_BASE_PATH = Symbol('DASHBOARD_BASE_PATH');\n/** DI token carrying the JSON API base the SPA fetches from (e.g. `/api/durable`). */\nexport const DASHBOARD_API_PATH = Symbol('DASHBOARD_API_PATH');\n\n/** The base the SPA bundle was built with (Vite `base`); rewritten to the configured base at serve time. */\nconst BUILD_BASE = '/durable';\n\n/** dist/server/durable-ui.controller.js -> ../spa (the Vite build output). */\nfunction spaDir(): string {\n return fileURLToPath(new URL('../spa', import.meta.url));\n}\n\nconst CONTENT_TYPES: Record<string, string> = {\n '.js': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.map': 'application/json; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.json': 'application/json; charset=utf-8',\n '.woff2': 'font/woff2',\n '.ico': 'image/x-icon',\n};\n\n/**\n * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).\n * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the\n * controller routes are relative.\n */\n@Controller()\nexport class DurableUiController {\n private readonly dir = spaDir();\n\n constructor(\n @Inject(DASHBOARD_BASE_PATH) private readonly basePath: string,\n @Inject(DASHBOARD_API_PATH) private readonly apiBasePath: string,\n ) {}\n\n // index.html references hash-named bundles, so it MUST NOT be cached (stale bundle = the\n // classic \"stuck loading after a deploy\"). The hashed assets below are immutable.\n @Get()\n @Header('Content-Type', 'text/html; charset=utf-8')\n @Header('Cache-Control', 'no-store, must-revalidate')\n index(): string {\n const indexPath = join(this.dir, 'index.html');\n if (!existsSync(indexPath)) {\n throw new NotFoundException('Dashboard is not built. Run the package build.');\n }\n // The bundle was built with Vite base `/durable/`; rewrite asset URLs to the configured base so\n // the SPA loads from `<base>/assets` wherever it's mounted, and tell the client its API base.\n const html = readFileSync(indexPath, 'utf8').replaceAll(\n `=\"${BUILD_BASE}/`,\n `=\"${this.basePath}/`,\n );\n // __DURABLE_BASE__ = where assets load; __DURABLE_API__ = where the SPA fetches the JSON API.\n const inject = `<script>window.__DURABLE_BASE__='${this.basePath}';window.__DURABLE_API__='${this.apiBasePath}';</script>`;\n return html.includes('</head>') ? html.replace('</head>', `${inject}</head>`) : inject + html;\n }\n\n @Get('assets/:file')\n @Header('Cache-Control', 'public, max-age=31536000, immutable')\n asset(@Param('file') file: string): StreamableFile {\n const safe = basename(file);\n if (safe !== file) throw new NotFoundException();\n const root = resolve(this.dir, 'assets');\n const assetPath = resolve(root, safe);\n if (!assetPath.startsWith(root + sep) || !existsSync(assetPath)) {\n throw new NotFoundException();\n }\n const type = CONTENT_TYPES[extname(safe)] ?? 'application/octet-stream';\n return new StreamableFile(readFileSync(assetPath), { type });\n }\n}\n"],"mappings":"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","names":["DashboardService","metricsCollector","store","engine","collectMetrics","metrics","cap","pending","running","dead","Promise","all","listRuns","status","limit","gauges","length","health","workerHealth","push","h","group","depth","liveWorkers","prometheus","join","query","getRunDetail","runId","run","getRun","timeline","children","listCheckpoints","getRunChildren","retryWithInput","input","retry","requeue","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","deliverWebhook","token","body","signal","getEvent","key","update","name","arg","streamRun","Observable","subscriber","off","subscribe","event","next","data","import_common","ATTR_OPS","Set","coerce","v","Number","isNaN","parseAttrFilters","attr","undefined","raw","Array","isArray","filters","entry","key","op","rest","split","has","length","push","value","join","DurableApiController","dashboard","runs","status","workflow","tag","attr","listRuns","attributes","parseAttrFilters","metrics","workers","workerHealth","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","webhook","token","deliverWebhook","event","key","getEvent","update","name","import_common","import_common","DASHBOARD_BASE_PATH","Symbol","DASHBOARD_API_PATH","BUILD_BASE","spaDir","fileURLToPath","URL","CONTENT_TYPES","DurableUiController","dir","basePath","apiBasePath","index","indexPath","join","existsSync","NotFoundException","html","readFileSync","replaceAll","inject","includes","replace","asset","file","safe","basename","root","resolve","assetPath","startsWith","sep","type","extname","StreamableFile","normalize","path","replace","DurableApiModule","controllers","DurableApiController","providers","DashboardService","exports","DurableDashboardModule","forRoot","options","basePath","apiBasePath","module","imports","RouterModule","register","DurableUiController","provide","DASHBOARD_BASE_PATH","useValue","DASHBOARD_API_PATH"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts","../../src/server/dashboard.service.ts","../../src/server/tokens.ts","../../src/server/durable-api.controller.ts","../../src/server/attr-filter.ts","../../src/server/durable-dashboard.module.ts","../../src/server/durable-ui.controller.ts"],"sourcesContent":["export * from './dashboard.service.js';\nexport * from './durable-api.controller.js';\nexport * from './durable-dashboard.module.js';\nexport * from './durable-ui.controller.js';\n","import {\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunGateway,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type StepCheckpoint,\n type UpdateResult,\n WorkflowEngine,\n type WorkflowRun,\n collectMetrics,\n} from '@dudousxd/nestjs-durable-core';\nimport { Inject, Injectable, Optional } from '@nestjs/common';\nimport { Observable } from 'rxjs';\nimport { RUN_GATEWAY } from './tokens.js';\n\nexport interface RunDetail {\n run: WorkflowRun;\n /** Steps in execution order — the end-to-end timeline (local + remote). */\n timeline: StepCheckpoint[];\n /** Ids of runs this run spawned (ctx.child / ctx.startChild) — the parent→children tree. */\n children: string[];\n}\n\n/**\n * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/\n * continue/retryWithInput/stream/bulk) route through the injected {@link RUN_GATEWAY} port, which\n * the lib binds on both topologies (a store-backed impl on the control plane, a proxy over the\n * transport on a store-less tenant) — so those ops work regardless of whether `store`/`engine`\n * are present. Operator-only ops (metrics/workerHealth/deliverWebhook/getEvent/update) still need\n * direct `store`/`engine` access; they stay `@Optional()` and throw a clear error when absent.\n */\n@Injectable()\nexport class DashboardService {\n /** Prometheus counters accumulated from engine events since boot (per process); undefined on a\n * store-less tenant (no engine to accumulate from). */\n private readonly metricsCollector: MetricsCollector | undefined;\n\n constructor(\n @Inject(RUN_GATEWAY) private readonly gateway: RunGateway,\n @Optional() @Inject(STATE_STORE_CANONICAL) private readonly store?: StateStore,\n @Optional() private readonly engine?: WorkflowEngine,\n ) {\n // Only the control plane (which has the canonical store) has a FULL engine to accumulate from;\n // on a tenant `this.engine` is a store-less start client with no `subscribe`, so guard on store.\n this.metricsCollector = this.store && this.engine ? collectMetrics(this.engine) : undefined;\n }\n\n /**\n * The operator-only ops need the FULL engine + canonical store, which only the control plane has.\n * On a tenant the `WorkflowEngine` token is a store-less start client that implements none of these\n * methods, so presence of the canonical store is the reliable discriminator — gate on it (never\n * on engine presence, which is truthy on a tenant too).\n */\n private controlPlane(): { store: StateStore; engine: WorkflowEngine } {\n if (!this.store || !this.engine) {\n throw new Error(\n 'This durable dashboard operation requires the control plane (not available on a tenant deployment).',\n );\n }\n return { store: this.store, engine: this.engine };\n }\n\n /**\n * Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,\n * per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`\n * (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ\n * size). Capped per status so the scrape can't load an unbounded result set. Control-plane-only.\n */\n async metrics(): Promise<string> {\n const { store, engine } = this.controlPlane();\n const cap = 10_000;\n const [pending, running, dead] = await Promise.all([\n store.listRuns({ status: 'pending', limit: cap }),\n store.listRuns({ status: 'running', limit: cap }),\n store.listRuns({ status: 'dead', limit: cap }),\n ]);\n const gauges = [\n '# TYPE durable_pending_runs gauge',\n `durable_pending_runs ${pending.length}`,\n '# TYPE durable_running_runs gauge',\n `durable_running_runs ${running.length}`,\n '# TYPE durable_dead_runs gauge',\n `durable_dead_runs ${dead.length}`,\n ];\n // Per-group worker health: backlog vs. live workers. `depth>0 && live==0` is the alert (work\n // piling up with no consumer) — expressible as a Prometheus rule on these two series.\n const health = await engine.workerHealth();\n if (health.length > 0) {\n gauges.push('# TYPE durable_group_queue_depth gauge');\n for (const h of health) {\n gauges.push(`durable_group_queue_depth{group=\"${h.group}\"} ${h.depth}`);\n }\n gauges.push('# TYPE durable_group_live_workers gauge');\n for (const h of health) {\n gauges.push(`durable_group_live_workers{group=\"${h.group}\"} ${h.liveWorkers.length}`);\n }\n }\n return `${this.metricsCollector?.prometheus() ?? ''}${gauges.join('\\n')}\\n`;\n }\n\n listRuns(query: RunQuery): Promise<WorkflowRun[]> {\n return this.gateway.listRuns(query);\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The\n * alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. Control-plane-only. */\n async workerHealth(): Promise<GroupHealth[]> {\n return this.controlPlane().engine.workerHealth();\n }\n\n getRunDetail(runId: string): Promise<RunDetail | null> {\n return this.gateway.getRunDetail(runId);\n }\n\n /** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */\n retryWithInput(runId: string, input: unknown): Promise<{ runId: string } | null> {\n return this.gateway.retryWithInput(runId, input);\n }\n\n /** Re-run a failed/incomplete run; completed steps replay from their checkpoints. */\n retry(runId: string): Promise<RunResult | null> {\n return this.gateway.retry(runId);\n }\n\n cancel(runId: string, opts?: { compensate?: boolean }): Promise<RunResult | null> {\n return this.gateway.cancel(runId, opts);\n }\n\n /**\n * Apply `retry` or `cancel` to every run matching a filter (status / tag / workflow) — e.g. \"retry\n * every `dead` run tagged `type:mel`\". Capped at 500 runs per call; runs that can't be acted on\n * (already terminal) are skipped. Returns how many matched and how many the action applied to.\n */\n async bulk(\n action: 'retry' | 'cancel',\n filter: Pick<RunQuery, 'status' | 'tag' | 'workflow' | 'attributes'>,\n opts?: { compensate?: boolean },\n ): Promise<{ matched: number; applied: number }> {\n const runs = await this.gateway.listRuns({ ...filter, limit: 500 });\n let applied = 0;\n for (const r of runs) {\n try {\n if (action === 'retry') await this.gateway.retry(r.id);\n else await this.gateway.cancel(r.id, opts);\n applied += 1;\n } catch {\n // Skip a run that can't take the action (e.g. already terminal) — keep going.\n }\n }\n return { matched: runs.length, applied };\n }\n\n /** Resume a run paused at a `ctx.breakpoint` (the \"continue\" button). */\n continue(runId: string): Promise<RunResult | null> {\n return this.gateway.continue(runId);\n }\n\n /**\n * Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the\n * waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a\n * stale/duplicate callback) — a safe no-op the controller maps to 404. Control-plane-only.\n */\n async deliverWebhook(token: string, body: unknown): Promise<RunResult | null> {\n return this.controlPlane().engine.signal(token, body);\n }\n\n /** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). Control-plane-only. */\n async getEvent(runId: string, key: string): Promise<unknown> {\n return this.controlPlane().engine.getEvent(runId, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). Control-plane-only. */\n async update(runId: string, name: string, arg: unknown): Promise<UpdateResult> {\n return this.controlPlane().engine.update(runId, name, arg);\n }\n\n /**\n * Live stream of a run's lifecycle events for SSE. Backed by `gateway.subscribe`, which — on the\n * control plane — receives events from EVERY instance (so a dashboard-only pod tails a run\n * executing on a worker pod), and on a tenant round-trips over the transport. Either way the\n * gateway already filters to `runId`; the dashboard just wires the events through.\n */\n streamRun(runId: string): Observable<{ data: EngineEvent }> {\n return new Observable<{ data: EngineEvent }>((subscriber) => {\n const off = this.gateway.subscribe(runId, (event) => subscriber.next({ data: event }));\n return () => off();\n });\n }\n}\n","/**\n * Injection token for the bound `RunGateway`, owned by `@dudousxd/nestjs-durable` (the nestjs\n * package). The dashboard package does NOT depend on `@dudousxd/nestjs-durable` — its only\n * relevant peer dep is `@dudousxd/nestjs-durable-core` — so we do NOT import the token from\n * there. Instead we share its well-known token by value, the same pattern\n * `@dudousxd/nestjs-durable`'s own `tokens.ts` uses for `CONTEXT_ACCESSOR` (a cross-lib token\n * owned by `@dudousxd/nestjs-context`).\n *\n * `Symbol.for(key)` uses the global symbol registry, so this resolves to the SAME symbol\n * instance as `packages/nestjs/src/tokens.ts`'s `RUN_GATEWAY` without any import. The key MUST\n * stay byte-identical with that export.\n */\nexport const RUN_GATEWAY = Symbol.for('nestjs-durable:run-gateway');\n","import type { RunStatus } from '@dudousxd/nestjs-durable-core';\nimport {\n Body,\n Controller,\n Get,\n Header,\n NotFoundException,\n Param,\n Post,\n Query,\n Sse,\n} from '@nestjs/common';\nimport { parseAttrFilters } from './attr-filter.js';\nimport { DashboardService } from './dashboard.service.js';\n\n/** JSON API consumed by the control-plane SPA. Mounted at `apiBasePath` (set by RouterModule). */\n@Controller()\nexport class DurableApiController {\n constructor(private readonly dashboard: DashboardService) {}\n\n @Get('runs')\n runs(\n @Query('status') status?: RunStatus,\n @Query('workflow') workflow?: string,\n @Query('tag') tag?: string,\n @Query('attr') attr?: string | string[],\n ) {\n return this.dashboard.listRuns({ status, workflow, tag, attributes: parseAttrFilters(attr) });\n }\n\n /** Prometheus-text metrics (runs/steps by outcome, per-workflow counts) for a scrape. */\n @Get('metrics')\n @Header('Content-Type', 'text/plain; version=0.0.4')\n metrics() {\n return this.dashboard.metrics();\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. */\n @Get('workers')\n workers() {\n return this.dashboard.workerHealth();\n }\n\n @Get('runs/:id')\n async run(@Param('id') id: string) {\n const detail = await this.dashboard.getRunDetail(id);\n if (!detail) throw new NotFoundException(`run ${id} not found`);\n return detail;\n }\n\n /** Server-Sent Events stream of a run's live lifecycle events — the dashboard tails it instead\n * of polling. Cross-pod when the transport has a control plane (see DashboardService.streamRun). */\n @Sse('runs/:id/stream')\n stream(@Param('id') id: string) {\n return this.dashboard.streamRun(id);\n }\n\n @Post('runs/:id/retry')\n retry(@Param('id') id: string) {\n return this.dashboard.retry(id);\n }\n\n /** Fix-and-replay: re-run a dead/failed run with a corrected input (a fresh linked run). */\n @Post('runs/:id/retry-with-input')\n retryWithInput(@Param('id') id: string, @Body() body: { input: unknown }) {\n return this.dashboard.retryWithInput(id, body?.input);\n }\n\n /** Bulk retry/cancel every run matching a filter (status / tag / workflow). */\n @Post('bulk/:action')\n bulk(\n @Param('action') action: 'retry' | 'cancel',\n @Query('status') status?: RunStatus,\n @Query('tag') tag?: string,\n @Query('workflow') workflow?: string,\n @Query('attr') attr?: string | string[],\n @Query('compensate') compensate?: string,\n ) {\n return this.dashboard.bulk(\n action === 'cancel' ? 'cancel' : 'retry',\n { status, tag, workflow, attributes: parseAttrFilters(attr) },\n { compensate: compensate === 'true' },\n );\n }\n\n @Post('runs/:id/cancel')\n async cancel(@Param('id') id: string, @Query('compensate') compensate?: string) {\n const result = await this.dashboard.cancel(id, { compensate: compensate === 'true' });\n if (!result) throw new NotFoundException(`run ${id} not found`);\n return result;\n }\n\n @Post('runs/:id/continue')\n async continue(@Param('id') id: string) {\n const result = await this.dashboard.continue(id);\n if (!result) throw new NotFoundException(`run ${id} is not paused at a breakpoint`);\n return result;\n }\n\n /**\n * Public callback endpoint for `ctx.webhook()`: a third party POSTs here (the url handed to it),\n * and the body resumes the waiting run. NOTE: this is reachable by external systems — protect it\n * by treating the token as a secret (it embeds runId:seq) and/or fronting it with signature\n * verification in your own middleware.\n */\n @Post('webhooks/:token')\n async webhook(@Param('token') token: string, @Body() body: unknown) {\n const result = await this.dashboard.deliverWebhook(token, body);\n if (!result) throw new NotFoundException(`no run is waiting on webhook ${token}`);\n return result;\n }\n\n /** Live query: the latest value a run published for `key` via `ctx.setEvent` (no side effect). */\n @Get('runs/:id/events/:key')\n event(@Param('id') id: string, @Param('key') key: string) {\n return this.dashboard.getEvent(id, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the body is the update argument. */\n @Post('runs/:id/updates/:name')\n update(@Param('id') id: string, @Param('name') name: string, @Body() body: unknown) {\n return this.dashboard.update(id, name, body);\n }\n}\n","import type { AttributeFilter, AttributeOp } from '@dudousxd/nestjs-durable-core';\n\nconst ATTR_OPS = new Set<AttributeOp>(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);\n\n/** Coerce a query-string value: `true`/`false` → boolean, numeric → number, else the raw string. */\nfunction coerce(v: string): string | number | boolean {\n if (v === 'true') return true;\n if (v === 'false') return false;\n if (v !== '' && !Number.isNaN(Number(v))) return Number(v);\n return v;\n}\n\n/**\n * Parse `attr=key:op:value` query params (repeatable, ANDed) into {@link AttributeFilter}s — e.g.\n * `?attr=amount:gte:200&attr=tier:eq:pro`. `op` must be a known operator; a colon in the value is\n * preserved (only the first two colons delimit). Malformed entries are skipped.\n */\nexport function parseAttrFilters(attr?: string | string[]): AttributeFilter[] | undefined {\n if (!attr) return undefined;\n const raw = Array.isArray(attr) ? attr : [attr];\n const filters: AttributeFilter[] = [];\n for (const entry of raw) {\n const [key, op, ...rest] = entry.split(':');\n if (!key || !ATTR_OPS.has(op as AttributeOp) || rest.length === 0) continue;\n filters.push({ key, op: op as AttributeOp, value: coerce(rest.join(':')) });\n }\n return filters.length ? filters : undefined;\n}\n","import { type DynamicModule, Module } from '@nestjs/common';\nimport { RouterModule } from '@nestjs/core';\nimport { DashboardService } from './dashboard.service.js';\nimport { DurableApiController } from './durable-api.controller.js';\nimport {\n DASHBOARD_API_PATH,\n DASHBOARD_BASE_PATH,\n DurableUiController,\n} from './durable-ui.controller.js';\n\nexport interface DurableDashboardOptions {\n /**\n * Where the SPA (UI) is served. Default `/durable`. This is a page route — keep it out of an\n * `/api` prefix so it reads as a UI, not an endpoint.\n */\n basePath?: string;\n /**\n * Where the JSON API is mounted (what the SPA fetches). Default `<basePath>/api`. Set it under\n * your app's `/api` prefix — e.g. `/api/durable` — so the API inherits the app's auth/proxy rules\n * while the UI stays at `basePath`.\n */\n apiBasePath?: string;\n}\n\n/** Leading slash, no trailing slash. */\nfunction normalize(path: string): string {\n return `/${path.replace(/^\\/+|\\/+$/g, '')}`;\n}\n\n/** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */\n@Module({\n controllers: [DurableApiController],\n providers: [DashboardService],\n exports: [DashboardService],\n})\nexport class DurableApiModule {}\n\n/**\n * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`\n * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside\n * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.\n */\n@Module({})\nexport class DurableDashboardModule {\n static forRoot(options: DurableDashboardOptions = {}): DynamicModule {\n const basePath = normalize(options.basePath ?? '/durable');\n const apiBasePath = normalize(options.apiBasePath ?? `${basePath}/api`);\n return {\n module: DurableDashboardModule,\n imports: [\n DurableApiModule,\n RouterModule.register([\n { path: basePath, module: DurableDashboardModule }, // the UI controller below\n { path: apiBasePath, module: DurableApiModule },\n ]),\n ],\n controllers: [DurableUiController],\n providers: [\n { provide: DASHBOARD_BASE_PATH, useValue: basePath },\n { provide: DASHBOARD_API_PATH, useValue: apiBasePath },\n ],\n // Re-export the API module so its DashboardService reaches importers (e.g. flip's own controllers).\n exports: [DurableApiModule],\n };\n }\n}\n","import { existsSync, readFileSync } from 'node:fs';\nimport { basename, extname, join, resolve, sep } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport {\n Controller,\n Get,\n Header,\n Inject,\n NotFoundException,\n Param,\n StreamableFile,\n} from '@nestjs/common';\n\n/** DI token carrying the UI mount base (e.g. `/durable`). */\nexport const DASHBOARD_BASE_PATH = Symbol('DASHBOARD_BASE_PATH');\n/** DI token carrying the JSON API base the SPA fetches from (e.g. `/api/durable`). */\nexport const DASHBOARD_API_PATH = Symbol('DASHBOARD_API_PATH');\n\n/** The base the SPA bundle was built with (Vite `base`); rewritten to the configured base at serve time. */\nconst BUILD_BASE = '/durable';\n\n/** dist/server/durable-ui.controller.js -> ../spa (the Vite build output). */\nfunction spaDir(): string {\n return fileURLToPath(new URL('../spa', import.meta.url));\n}\n\nconst CONTENT_TYPES: Record<string, string> = {\n '.js': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.map': 'application/json; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.json': 'application/json; charset=utf-8',\n '.woff2': 'font/woff2',\n '.ico': 'image/x-icon',\n};\n\n/**\n * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).\n * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the\n * controller routes are relative.\n */\n@Controller()\nexport class DurableUiController {\n private readonly dir = spaDir();\n\n constructor(\n @Inject(DASHBOARD_BASE_PATH) private readonly basePath: string,\n @Inject(DASHBOARD_API_PATH) private readonly apiBasePath: string,\n ) {}\n\n // index.html references hash-named bundles, so it MUST NOT be cached (stale bundle = the\n // classic \"stuck loading after a deploy\"). The hashed assets below are immutable.\n @Get()\n @Header('Content-Type', 'text/html; charset=utf-8')\n @Header('Cache-Control', 'no-store, must-revalidate')\n index(): string {\n const indexPath = join(this.dir, 'index.html');\n if (!existsSync(indexPath)) {\n throw new NotFoundException('Dashboard is not built. Run the package build.');\n }\n // The bundle was built with Vite base `/durable/`; rewrite asset URLs to the configured base so\n // the SPA loads from `<base>/assets` wherever it's mounted, and tell the client its API base.\n const html = readFileSync(indexPath, 'utf8').replaceAll(\n `=\"${BUILD_BASE}/`,\n `=\"${this.basePath}/`,\n );\n // __DURABLE_BASE__ = where assets load; __DURABLE_API__ = where the SPA fetches the JSON API.\n const inject = `<script>window.__DURABLE_BASE__='${this.basePath}';window.__DURABLE_API__='${this.apiBasePath}';</script>`;\n return html.includes('</head>') ? html.replace('</head>', `${inject}</head>`) : inject + html;\n }\n\n @Get('assets/:file')\n @Header('Cache-Control', 'public, max-age=31536000, immutable')\n asset(@Param('file') file: string): StreamableFile {\n const safe = basename(file);\n if (safe !== file) throw new NotFoundException();\n const root = resolve(this.dir, 'assets');\n const assetPath = resolve(root, safe);\n if (!assetPath.startsWith(root + sep) || !existsSync(assetPath)) {\n throw new NotFoundException();\n }\n const type = CONTENT_TYPES[extname(safe)] ?? 'application/octet-stream';\n return new StreamableFile(readFileSync(assetPath), { type });\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA;;;;;;;;;;;;;ACAA,iCAcO;AACP,oBAA6C;AAC7C,kBAA2B;;;ACJpB,IAAMA,cAAcC,OAAOC,IAAI,4BAAA;;;;;;;;;;;;;;;;;;;;ADwB/B,IAAMC,mBAAN,MAAMA;SAAAA;;;;;;;;EAGMC;EAEjB,YACwCC,SACsBC,OAC/BC,QAC7B;SAHsCF,UAAAA;SACsBC,QAAAA;SAC/BC,SAAAA;AAI7B,SAAKH,mBAAmB,KAAKE,SAAS,KAAKC,aAASC,2CAAe,KAAKD,MAAM,IAAIE;EACpF;;;;;;;EAQQC,eAA8D;AACpE,QAAI,CAAC,KAAKJ,SAAS,CAAC,KAAKC,QAAQ;AAC/B,YAAM,IAAII,MACR,qGAAA;IAEJ;AACA,WAAO;MAAEL,OAAO,KAAKA;MAAOC,QAAQ,KAAKA;IAAO;EAClD;;;;;;;EAQA,MAAMK,UAA2B;AAC/B,UAAM,EAAEN,OAAOC,OAAM,IAAK,KAAKG,aAAY;AAC3C,UAAMG,MAAM;AACZ,UAAM,CAACC,SAASC,SAASC,IAAAA,IAAQ,MAAMC,QAAQC,IAAI;MACjDZ,MAAMa,SAAS;QAAEC,QAAQ;QAAWC,OAAOR;MAAI,CAAA;MAC/CP,MAAMa,SAAS;QAAEC,QAAQ;QAAWC,OAAOR;MAAI,CAAA;MAC/CP,MAAMa,SAAS;QAAEC,QAAQ;QAAQC,OAAOR;MAAI,CAAA;KAC7C;AACD,UAAMS,SAAS;MACb;MACA,wBAAwBR,QAAQS,MAAM;MACtC;MACA,wBAAwBR,QAAQQ,MAAM;MACtC;MACA,qBAAqBP,KAAKO,MAAM;;AAIlC,UAAMC,SAAS,MAAMjB,OAAOkB,aAAY;AACxC,QAAID,OAAOD,SAAS,GAAG;AACrBD,aAAOI,KAAK,wCAAA;AACZ,iBAAWC,KAAKH,QAAQ;AACtBF,eAAOI,KAAK,oCAAoCC,EAAEC,KAAK,MAAMD,EAAEE,KAAK,EAAE;MACxE;AACAP,aAAOI,KAAK,yCAAA;AACZ,iBAAWC,KAAKH,QAAQ;AACtBF,eAAOI,KAAK,qCAAqCC,EAAEC,KAAK,MAAMD,EAAEG,YAAYP,MAAM,EAAE;MACtF;IACF;AACA,WAAO,GAAG,KAAKnB,kBAAkB2B,WAAAA,KAAgB,EAAA,GAAKT,OAAOU,KAAK,IAAA,CAAA;;EACpE;EAEAb,SAASc,OAAyC;AAChD,WAAO,KAAK5B,QAAQc,SAASc,KAAAA;EAC/B;;;EAIA,MAAMR,eAAuC;AAC3C,WAAO,KAAKf,aAAY,EAAGH,OAAOkB,aAAY;EAChD;EAEAS,aAAaC,OAA0C;AACrD,WAAO,KAAK9B,QAAQ6B,aAAaC,KAAAA;EACnC;;EAGAC,eAAeD,OAAeE,OAAmD;AAC/E,WAAO,KAAKhC,QAAQ+B,eAAeD,OAAOE,KAAAA;EAC5C;;EAGAC,MAAMH,OAA0C;AAC9C,WAAO,KAAK9B,QAAQiC,MAAMH,KAAAA;EAC5B;EAEAI,OAAOJ,OAAeK,MAA4D;AAChF,WAAO,KAAKnC,QAAQkC,OAAOJ,OAAOK,IAAAA;EACpC;;;;;;EAOA,MAAMC,KACJC,QACAC,QACAH,MAC+C;AAC/C,UAAMI,OAAO,MAAM,KAAKvC,QAAQc,SAAS;MAAE,GAAGwB;MAAQtB,OAAO;IAAI,CAAA;AACjE,QAAIwB,UAAU;AACd,eAAWC,KAAKF,MAAM;AACpB,UAAI;AACF,YAAIF,WAAW,QAAS,OAAM,KAAKrC,QAAQiC,MAAMQ,EAAEC,EAAE;YAChD,OAAM,KAAK1C,QAAQkC,OAAOO,EAAEC,IAAIP,IAAAA;AACrCK,mBAAW;MACb,QAAQ;MAER;IACF;AACA,WAAO;MAAEG,SAASJ,KAAKrB;MAAQsB;IAAQ;EACzC;;EAGAI,SAASd,OAA0C;AACjD,WAAO,KAAK9B,QAAQ4C,SAASd,KAAAA;EAC/B;;;;;;EAOA,MAAMe,eAAeC,OAAeC,MAA0C;AAC5E,WAAO,KAAK1C,aAAY,EAAGH,OAAO8C,OAAOF,OAAOC,IAAAA;EAClD;;EAGA,MAAME,SAASnB,OAAeoB,KAA+B;AAC3D,WAAO,KAAK7C,aAAY,EAAGH,OAAO+C,SAASnB,OAAOoB,GAAAA;EACpD;;EAGA,MAAMC,OAAOrB,OAAesB,MAAcC,KAAqC;AAC7E,WAAO,KAAKhD,aAAY,EAAGH,OAAOiD,OAAOrB,OAAOsB,MAAMC,GAAAA;EACxD;;;;;;;EAQAC,UAAUxB,OAAkD;AAC1D,WAAO,IAAIyB,uBAAkC,CAACC,eAAAA;AAC5C,YAAMC,MAAM,KAAKzD,QAAQ0D,UAAU5B,OAAO,CAAC6B,UAAUH,WAAWI,KAAK;QAAEC,MAAMF;MAAM,CAAA,CAAA;AACnF,aAAO,MAAMF,IAAAA;IACf,CAAA;EACF;AACF;;;;;;;;;;;;;;;;AE/LA,IAAAK,iBAUO;;;ACTP,IAAMC,WAAW,oBAAIC,IAAiB;EAAC;EAAM;EAAM;EAAM;EAAO;EAAM;CAAM;AAG5E,SAASC,OAAOC,GAAS;AACvB,MAAIA,MAAM,OAAQ,QAAO;AACzB,MAAIA,MAAM,QAAS,QAAO;AAC1B,MAAIA,MAAM,MAAM,CAACC,OAAOC,MAAMD,OAAOD,CAAAA,CAAAA,EAAK,QAAOC,OAAOD,CAAAA;AACxD,SAAOA;AACT;AALSD;AAYF,SAASI,iBAAiBC,MAAwB;AACvD,MAAI,CAACA,KAAM,QAAOC;AAClB,QAAMC,MAAMC,MAAMC,QAAQJ,IAAAA,IAAQA,OAAO;IAACA;;AAC1C,QAAMK,UAA6B,CAAA;AACnC,aAAWC,SAASJ,KAAK;AACvB,UAAM,CAACK,KAAKC,IAAI,GAAGC,IAAAA,IAAQH,MAAMI,MAAM,GAAA;AACvC,QAAI,CAACH,OAAO,CAACd,SAASkB,IAAIH,EAAAA,KAAsBC,KAAKG,WAAW,EAAG;AACnEP,YAAQQ,KAAK;MAAEN;MAAKC;MAAuBM,OAAOnB,OAAOc,KAAKM,KAAK,GAAA,CAAA;IAAM,CAAA;EAC3E;AACA,SAAOV,QAAQO,SAASP,UAAUJ;AACpC;AAVgBF;;;;;;;;;;;;;;;;;;;;ADAT,IAAMiB,uBAAN,MAAMA;SAAAA;;;;EACX,YAA6BC,WAA6B;SAA7BA,YAAAA;EAA8B;EAG3DC,KACmBC,QACEC,UACLC,KACCC,MACf;AACA,WAAO,KAAKL,UAAUM,SAAS;MAAEJ;MAAQC;MAAUC;MAAKG,YAAYC,iBAAiBH,IAAAA;IAAM,CAAA;EAC7F;;EAKAI,UAAU;AACR,WAAO,KAAKT,UAAUS,QAAO;EAC/B;;EAIAC,UAAU;AACR,WAAO,KAAKV,UAAUW,aAAY;EACpC;EAEA,MACMC,IAAiBC,IAAY;AACjC,UAAMC,SAAS,MAAM,KAAKd,UAAUe,aAAaF,EAAAA;AACjD,QAAI,CAACC,OAAQ,OAAM,IAAIE,iCAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOC;EACT;;;EAKAG,OAAoBJ,IAAY;AAC9B,WAAO,KAAKb,UAAUkB,UAAUL,EAAAA;EAClC;EAGAM,MAAmBN,IAAY;AAC7B,WAAO,KAAKb,UAAUmB,MAAMN,EAAAA;EAC9B;;EAIAO,eAA4BP,IAAoBQ,MAA0B;AACxE,WAAO,KAAKrB,UAAUoB,eAAeP,IAAIQ,MAAMC,KAAAA;EACjD;;EAIAC,KACmBC,QACAtB,QACHE,KACKD,UACJE,MACMoB,YACrB;AACA,WAAO,KAAKzB,UAAUuB,KACpBC,WAAW,WAAW,WAAW,SACjC;MAAEtB;MAAQE;MAAKD;MAAUI,YAAYC,iBAAiBH,IAAAA;IAAM,GAC5D;MAAEoB,YAAYA,eAAe;IAAO,CAAA;EAExC;EAEA,MACMC,OAAoBb,IAAiCY,YAAqB;AAC9E,UAAME,SAAS,MAAM,KAAK3B,UAAU0B,OAAOb,IAAI;MAAEY,YAAYA,eAAe;IAAO,CAAA;AACnF,QAAI,CAACE,OAAQ,OAAM,IAAIX,iCAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOc;EACT;EAEA,MACMC,SAAsBf,IAAY;AACtC,UAAMc,SAAS,MAAM,KAAK3B,UAAU4B,SAASf,EAAAA;AAC7C,QAAI,CAACc,OAAQ,OAAM,IAAIX,iCAAkB,OAAOH,EAAAA,gCAAkC;AAClF,WAAOc;EACT;;;;;;;EAQA,MACME,QAAwBC,OAAuBT,MAAe;AAClE,UAAMM,SAAS,MAAM,KAAK3B,UAAU+B,eAAeD,OAAOT,IAAAA;AAC1D,QAAI,CAACM,OAAQ,OAAM,IAAIX,iCAAkB,gCAAgCc,KAAAA,EAAO;AAChF,WAAOH;EACT;;EAIAK,MAAmBnB,IAA0BoB,KAAa;AACxD,WAAO,KAAKjC,UAAUkC,SAASrB,IAAIoB,GAAAA;EACrC;;EAIAE,OAAoBtB,IAA2BuB,MAAsBf,MAAe;AAClF,WAAO,KAAKrB,UAAUmC,OAAOtB,IAAIuB,MAAMf,IAAAA;EACzC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AE3HA,IAAAgB,iBAA2C;AAC3C,kBAA6B;;;ACD7B,qBAAyC;AACzC,uBAAsD;AACtD,sBAA8B;AAC9B,IAAAC,iBAQO;;;;;;;;;;;;;;;;;;AAGA,IAAMC,sBAAsBC,OAAO,qBAAA;AAEnC,IAAMC,qBAAqBD,OAAO,oBAAA;AAGzC,IAAME,aAAa;AAGnB,SAASC,SAAAA;AACP,aAAOC,+BAAc,IAAIC,IAAI,UAAU,eAAe,CAAA;AACxD;AAFSF;AAIT,IAAMG,gBAAwC;EAC5C,OAAO;EACP,QAAQ;EACR,QAAQ;EACR,QAAQ;EACR,SAAS;EACT,UAAU;EACV,QAAQ;AACV;AAQO,IAAMC,sBAAN,MAAMA;SAAAA;;;;;EACMC,MAAML,OAAAA;EAEvB,YACgDM,UACDC,aAC7C;SAF8CD,WAAAA;SACDC,cAAAA;EAC5C;;;EAOHC,QAAgB;AACd,UAAMC,gBAAYC,uBAAK,KAAKL,KAAK,YAAA;AACjC,QAAI,KAACM,2BAAWF,SAAAA,GAAY;AAC1B,YAAM,IAAIG,iCAAkB,gDAAA;IAC9B;AAGA,UAAMC,WAAOC,6BAAaL,WAAW,MAAA,EAAQM,WAC3C,KAAKhB,UAAAA,KACL,KAAK,KAAKO,QAAQ,GAAG;AAGvB,UAAMU,SAAS,oCAAoC,KAAKV,QAAQ,6BAA6B,KAAKC,WAAW;AAC7G,WAAOM,KAAKI,SAAS,SAAA,IAAaJ,KAAKK,QAAQ,WAAW,GAAGF,MAAAA,SAAe,IAAIA,SAASH;EAC3F;EAIAM,MAAqBC,MAA8B;AACjD,UAAMC,WAAOC,2BAASF,IAAAA;AACtB,QAAIC,SAASD,KAAM,OAAM,IAAIR,iCAAAA;AAC7B,UAAMW,WAAOC,0BAAQ,KAAKnB,KAAK,QAAA;AAC/B,UAAMoB,gBAAYD,0BAAQD,MAAMF,IAAAA;AAChC,QAAI,CAACI,UAAUC,WAAWH,OAAOI,oBAAAA,KAAQ,KAAChB,2BAAWc,SAAAA,GAAY;AAC/D,YAAM,IAAIb,iCAAAA;IACZ;AACA,UAAMgB,OAAOzB,kBAAc0B,0BAAQR,IAAAA,CAAAA,KAAU;AAC7C,WAAO,IAAIS,kCAAehB,6BAAaW,SAAAA,GAAY;MAAEG;IAAK,CAAA;EAC5D;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AD3DA,SAASG,UAAUC,MAAY;AAC7B,SAAO,IAAIA,KAAKC,QAAQ,cAAc,EAAA,CAAA;AACxC;AAFSF;AAUF,IAAMG,mBAAN,MAAMA;SAAAA;;;AAAkB;;;IAJ7BC,aAAa;MAACC;;IACdC,WAAW;MAACC;;IACZC,SAAS;MAACD;;;;AAUL,IAAME,yBAAN,MAAMA,wBAAAA;SAAAA;;;EACX,OAAOC,QAAQC,UAAmC,CAAC,GAAkB;AACnE,UAAMC,WAAWZ,UAAUW,QAAQC,YAAY,UAAA;AAC/C,UAAMC,cAAcb,UAAUW,QAAQE,eAAe,GAAGD,QAAAA,MAAc;AACtE,WAAO;MACLE,QAAQL;MACRM,SAAS;QACPZ;QACAa,yBAAaC,SAAS;UACpB;YAAEhB,MAAMW;YAAUE,QAAQL;UAAuB;UACjD;YAAER,MAAMY;YAAaC,QAAQX;UAAiB;SAC/C;;MAEHC,aAAa;QAACc;;MACdZ,WAAW;QACT;UAAEa,SAASC;UAAqBC,UAAUT;QAAS;QACnD;UAAEO,SAASG;UAAoBD,UAAUR;QAAY;;;MAGvDL,SAAS;QAACL;;IACZ;EACF;AACF;;;;","names":["RUN_GATEWAY","Symbol","for","DashboardService","metricsCollector","gateway","store","engine","collectMetrics","undefined","controlPlane","Error","metrics","cap","pending","running","dead","Promise","all","listRuns","status","limit","gauges","length","health","workerHealth","push","h","group","depth","liveWorkers","prometheus","join","query","getRunDetail","runId","retryWithInput","input","retry","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","deliverWebhook","token","body","signal","getEvent","key","update","name","arg","streamRun","Observable","subscriber","off","subscribe","event","next","data","import_common","ATTR_OPS","Set","coerce","v","Number","isNaN","parseAttrFilters","attr","undefined","raw","Array","isArray","filters","entry","key","op","rest","split","has","length","push","value","join","DurableApiController","dashboard","runs","status","workflow","tag","attr","listRuns","attributes","parseAttrFilters","metrics","workers","workerHealth","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","webhook","token","deliverWebhook","event","key","getEvent","update","name","import_common","import_common","DASHBOARD_BASE_PATH","Symbol","DASHBOARD_API_PATH","BUILD_BASE","spaDir","fileURLToPath","URL","CONTENT_TYPES","DurableUiController","dir","basePath","apiBasePath","index","indexPath","join","existsSync","NotFoundException","html","readFileSync","replaceAll","inject","includes","replace","asset","file","safe","basename","root","resolve","assetPath","startsWith","sep","type","extname","StreamableFile","normalize","path","replace","DurableApiModule","controllers","DurableApiController","providers","DashboardService","exports","DurableDashboardModule","forRoot","options","basePath","apiBasePath","module","imports","RouterModule","register","DurableUiController","provide","DASHBOARD_BASE_PATH","useValue","DASHBOARD_API_PATH"]}
|
package/dist/server/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _dudousxd_nestjs_durable_core from '@dudousxd/nestjs-durable-core';
|
|
2
|
-
import { StateStore, WorkflowEngine, RunQuery, WorkflowRun, GroupHealth, StepCheckpoint, RunResult, UpdateResult, EngineEvent, RunStatus } from '@dudousxd/nestjs-durable-core';
|
|
2
|
+
import { RunGateway, StateStore, WorkflowEngine, RunQuery, WorkflowRun, GroupHealth, StepCheckpoint, RunResult, UpdateResult, EngineEvent, RunStatus } from '@dudousxd/nestjs-durable-core';
|
|
3
3
|
import * as rxjs from 'rxjs';
|
|
4
4
|
import { Observable } from 'rxjs';
|
|
5
5
|
import { DynamicModule, StreamableFile } from '@nestjs/common';
|
|
@@ -11,23 +11,39 @@ interface RunDetail {
|
|
|
11
11
|
/** Ids of runs this run spawned (ctx.child / ctx.startChild) — the parent→children tree. */
|
|
12
12
|
children: string[];
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/
|
|
16
|
+
* continue/retryWithInput/stream/bulk) route through the injected {@link RUN_GATEWAY} port, which
|
|
17
|
+
* the lib binds on both topologies (a store-backed impl on the control plane, a proxy over the
|
|
18
|
+
* transport on a store-less tenant) — so those ops work regardless of whether `store`/`engine`
|
|
19
|
+
* are present. Operator-only ops (metrics/workerHealth/deliverWebhook/getEvent/update) still need
|
|
20
|
+
* direct `store`/`engine` access; they stay `@Optional()` and throw a clear error when absent.
|
|
21
|
+
*/
|
|
15
22
|
declare class DashboardService {
|
|
16
|
-
private readonly
|
|
17
|
-
private readonly
|
|
18
|
-
|
|
23
|
+
private readonly gateway;
|
|
24
|
+
private readonly store?;
|
|
25
|
+
private readonly engine?;
|
|
26
|
+
/** Prometheus counters accumulated from engine events since boot (per process); undefined on a
|
|
27
|
+
* store-less tenant (no engine to accumulate from). */
|
|
19
28
|
private readonly metricsCollector;
|
|
20
|
-
constructor(
|
|
29
|
+
constructor(gateway: RunGateway, store?: StateStore | undefined, engine?: WorkflowEngine | undefined);
|
|
30
|
+
/**
|
|
31
|
+
* The operator-only ops need the FULL engine + canonical store, which only the control plane has.
|
|
32
|
+
* On a tenant the `WorkflowEngine` token is a store-less start client that implements none of these
|
|
33
|
+
* methods, so presence of the canonical store is the reliable discriminator — gate on it (never
|
|
34
|
+
* on engine presence, which is truthy on a tenant too).
|
|
35
|
+
*/
|
|
36
|
+
private controlPlane;
|
|
21
37
|
/**
|
|
22
38
|
* Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,
|
|
23
39
|
* per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`
|
|
24
40
|
* (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ
|
|
25
|
-
* size). Capped per status so the scrape can't load an unbounded result set.
|
|
41
|
+
* size). Capped per status so the scrape can't load an unbounded result set. Control-plane-only.
|
|
26
42
|
*/
|
|
27
43
|
metrics(): Promise<string>;
|
|
28
44
|
listRuns(query: RunQuery): Promise<WorkflowRun[]>;
|
|
29
45
|
/** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The
|
|
30
|
-
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. */
|
|
46
|
+
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. Control-plane-only. */
|
|
31
47
|
workerHealth(): Promise<GroupHealth[]>;
|
|
32
48
|
getRunDetail(runId: string): Promise<RunDetail | null>;
|
|
33
49
|
/** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */
|
|
@@ -55,17 +71,18 @@ declare class DashboardService {
|
|
|
55
71
|
/**
|
|
56
72
|
* Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the
|
|
57
73
|
* waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a
|
|
58
|
-
* stale/duplicate callback) — a safe no-op the controller maps to 404.
|
|
74
|
+
* stale/duplicate callback) — a safe no-op the controller maps to 404. Control-plane-only.
|
|
59
75
|
*/
|
|
60
76
|
deliverWebhook(token: string, body: unknown): Promise<RunResult | null>;
|
|
61
|
-
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). */
|
|
77
|
+
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). Control-plane-only. */
|
|
62
78
|
getEvent(runId: string, key: string): Promise<unknown>;
|
|
63
|
-
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). */
|
|
79
|
+
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). Control-plane-only. */
|
|
64
80
|
update(runId: string, name: string, arg: unknown): Promise<UpdateResult>;
|
|
65
81
|
/**
|
|
66
|
-
* Live stream of a run's lifecycle events for SSE. Backed by `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
82
|
+
* Live stream of a run's lifecycle events for SSE. Backed by `gateway.subscribe`, which — on the
|
|
83
|
+
* control plane — receives events from EVERY instance (so a dashboard-only pod tails a run
|
|
84
|
+
* executing on a worker pod), and on a tenant round-trips over the transport. Either way the
|
|
85
|
+
* gateway already filters to `runId`; the dashboard just wires the events through.
|
|
69
86
|
*/
|
|
70
87
|
streamRun(runId: string): Observable<{
|
|
71
88
|
data: EngineEvent;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _dudousxd_nestjs_durable_core from '@dudousxd/nestjs-durable-core';
|
|
2
|
-
import { StateStore, WorkflowEngine, RunQuery, WorkflowRun, GroupHealth, StepCheckpoint, RunResult, UpdateResult, EngineEvent, RunStatus } from '@dudousxd/nestjs-durable-core';
|
|
2
|
+
import { RunGateway, StateStore, WorkflowEngine, RunQuery, WorkflowRun, GroupHealth, StepCheckpoint, RunResult, UpdateResult, EngineEvent, RunStatus } from '@dudousxd/nestjs-durable-core';
|
|
3
3
|
import * as rxjs from 'rxjs';
|
|
4
4
|
import { Observable } from 'rxjs';
|
|
5
5
|
import { DynamicModule, StreamableFile } from '@nestjs/common';
|
|
@@ -11,23 +11,39 @@ interface RunDetail {
|
|
|
11
11
|
/** Ids of runs this run spawned (ctx.child / ctx.startChild) — the parent→children tree. */
|
|
12
12
|
children: string[];
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/
|
|
16
|
+
* continue/retryWithInput/stream/bulk) route through the injected {@link RUN_GATEWAY} port, which
|
|
17
|
+
* the lib binds on both topologies (a store-backed impl on the control plane, a proxy over the
|
|
18
|
+
* transport on a store-less tenant) — so those ops work regardless of whether `store`/`engine`
|
|
19
|
+
* are present. Operator-only ops (metrics/workerHealth/deliverWebhook/getEvent/update) still need
|
|
20
|
+
* direct `store`/`engine` access; they stay `@Optional()` and throw a clear error when absent.
|
|
21
|
+
*/
|
|
15
22
|
declare class DashboardService {
|
|
16
|
-
private readonly
|
|
17
|
-
private readonly
|
|
18
|
-
|
|
23
|
+
private readonly gateway;
|
|
24
|
+
private readonly store?;
|
|
25
|
+
private readonly engine?;
|
|
26
|
+
/** Prometheus counters accumulated from engine events since boot (per process); undefined on a
|
|
27
|
+
* store-less tenant (no engine to accumulate from). */
|
|
19
28
|
private readonly metricsCollector;
|
|
20
|
-
constructor(
|
|
29
|
+
constructor(gateway: RunGateway, store?: StateStore | undefined, engine?: WorkflowEngine | undefined);
|
|
30
|
+
/**
|
|
31
|
+
* The operator-only ops need the FULL engine + canonical store, which only the control plane has.
|
|
32
|
+
* On a tenant the `WorkflowEngine` token is a store-less start client that implements none of these
|
|
33
|
+
* methods, so presence of the canonical store is the reliable discriminator — gate on it (never
|
|
34
|
+
* on engine presence, which is truthy on a tenant too).
|
|
35
|
+
*/
|
|
36
|
+
private controlPlane;
|
|
21
37
|
/**
|
|
22
38
|
* Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,
|
|
23
39
|
* per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`
|
|
24
40
|
* (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ
|
|
25
|
-
* size). Capped per status so the scrape can't load an unbounded result set.
|
|
41
|
+
* size). Capped per status so the scrape can't load an unbounded result set. Control-plane-only.
|
|
26
42
|
*/
|
|
27
43
|
metrics(): Promise<string>;
|
|
28
44
|
listRuns(query: RunQuery): Promise<WorkflowRun[]>;
|
|
29
45
|
/** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The
|
|
30
|
-
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. */
|
|
46
|
+
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. Control-plane-only. */
|
|
31
47
|
workerHealth(): Promise<GroupHealth[]>;
|
|
32
48
|
getRunDetail(runId: string): Promise<RunDetail | null>;
|
|
33
49
|
/** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */
|
|
@@ -55,17 +71,18 @@ declare class DashboardService {
|
|
|
55
71
|
/**
|
|
56
72
|
* Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the
|
|
57
73
|
* waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a
|
|
58
|
-
* stale/duplicate callback) — a safe no-op the controller maps to 404.
|
|
74
|
+
* stale/duplicate callback) — a safe no-op the controller maps to 404. Control-plane-only.
|
|
59
75
|
*/
|
|
60
76
|
deliverWebhook(token: string, body: unknown): Promise<RunResult | null>;
|
|
61
|
-
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). */
|
|
77
|
+
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). Control-plane-only. */
|
|
62
78
|
getEvent(runId: string, key: string): Promise<unknown>;
|
|
63
|
-
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). */
|
|
79
|
+
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). Control-plane-only. */
|
|
64
80
|
update(runId: string, name: string, arg: unknown): Promise<UpdateResult>;
|
|
65
81
|
/**
|
|
66
|
-
* Live stream of a run's lifecycle events for SSE. Backed by `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
82
|
+
* Live stream of a run's lifecycle events for SSE. Backed by `gateway.subscribe`, which — on the
|
|
83
|
+
* control plane — receives events from EVERY instance (so a dashboard-only pod tails a run
|
|
84
|
+
* executing on a worker pod), and on a tenant round-trips over the transport. Either way the
|
|
85
|
+
* gateway already filters to `runId`; the dashboard just wires the events through.
|
|
69
86
|
*/
|
|
70
87
|
streamRun(runId: string): Observable<{
|
|
71
88
|
data: EngineEvent;
|
package/dist/server/index.js
CHANGED
|
@@ -3,8 +3,13 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
|
|
|
3
3
|
|
|
4
4
|
// src/server/dashboard.service.ts
|
|
5
5
|
import { STATE_STORE_CANONICAL, WorkflowEngine, collectMetrics } from "@dudousxd/nestjs-durable-core";
|
|
6
|
-
import { Inject, Injectable } from "@nestjs/common";
|
|
6
|
+
import { Inject, Injectable, Optional } from "@nestjs/common";
|
|
7
7
|
import { Observable } from "rxjs";
|
|
8
|
+
|
|
9
|
+
// src/server/tokens.ts
|
|
10
|
+
var RUN_GATEWAY = Symbol.for("nestjs-durable:run-gateway");
|
|
11
|
+
|
|
12
|
+
// src/server/dashboard.service.ts
|
|
8
13
|
function _ts_decorate(decorators, target, key, desc) {
|
|
9
14
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
10
15
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -26,33 +31,52 @@ var DashboardService = class {
|
|
|
26
31
|
static {
|
|
27
32
|
__name(this, "DashboardService");
|
|
28
33
|
}
|
|
34
|
+
gateway;
|
|
29
35
|
store;
|
|
30
36
|
engine;
|
|
31
|
-
/** Prometheus counters accumulated from engine events since boot (per process)
|
|
37
|
+
/** Prometheus counters accumulated from engine events since boot (per process); undefined on a
|
|
38
|
+
* store-less tenant (no engine to accumulate from). */
|
|
32
39
|
metricsCollector;
|
|
33
|
-
constructor(store, engine) {
|
|
40
|
+
constructor(gateway, store, engine) {
|
|
41
|
+
this.gateway = gateway;
|
|
34
42
|
this.store = store;
|
|
35
43
|
this.engine = engine;
|
|
36
|
-
this.metricsCollector = collectMetrics(this.engine);
|
|
44
|
+
this.metricsCollector = this.store && this.engine ? collectMetrics(this.engine) : void 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The operator-only ops need the FULL engine + canonical store, which only the control plane has.
|
|
48
|
+
* On a tenant the `WorkflowEngine` token is a store-less start client that implements none of these
|
|
49
|
+
* methods, so presence of the canonical store is the reliable discriminator — gate on it (never
|
|
50
|
+
* on engine presence, which is truthy on a tenant too).
|
|
51
|
+
*/
|
|
52
|
+
controlPlane() {
|
|
53
|
+
if (!this.store || !this.engine) {
|
|
54
|
+
throw new Error("This durable dashboard operation requires the control plane (not available on a tenant deployment).");
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
store: this.store,
|
|
58
|
+
engine: this.engine
|
|
59
|
+
};
|
|
37
60
|
}
|
|
38
61
|
/**
|
|
39
62
|
* Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,
|
|
40
63
|
* per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`
|
|
41
64
|
* (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ
|
|
42
|
-
* size). Capped per status so the scrape can't load an unbounded result set.
|
|
65
|
+
* size). Capped per status so the scrape can't load an unbounded result set. Control-plane-only.
|
|
43
66
|
*/
|
|
44
67
|
async metrics() {
|
|
68
|
+
const { store, engine } = this.controlPlane();
|
|
45
69
|
const cap = 1e4;
|
|
46
70
|
const [pending, running, dead] = await Promise.all([
|
|
47
|
-
|
|
71
|
+
store.listRuns({
|
|
48
72
|
status: "pending",
|
|
49
73
|
limit: cap
|
|
50
74
|
}),
|
|
51
|
-
|
|
75
|
+
store.listRuns({
|
|
52
76
|
status: "running",
|
|
53
77
|
limit: cap
|
|
54
78
|
}),
|
|
55
|
-
|
|
79
|
+
store.listRuns({
|
|
56
80
|
status: "dead",
|
|
57
81
|
limit: cap
|
|
58
82
|
})
|
|
@@ -65,7 +89,7 @@ var DashboardService = class {
|
|
|
65
89
|
"# TYPE durable_dead_runs gauge",
|
|
66
90
|
`durable_dead_runs ${dead.length}`
|
|
67
91
|
];
|
|
68
|
-
const health = await
|
|
92
|
+
const health = await engine.workerHealth();
|
|
69
93
|
if (health.length > 0) {
|
|
70
94
|
gauges.push("# TYPE durable_group_queue_depth gauge");
|
|
71
95
|
for (const h of health) {
|
|
@@ -76,40 +100,30 @@ var DashboardService = class {
|
|
|
76
100
|
gauges.push(`durable_group_live_workers{group="${h.group}"} ${h.liveWorkers.length}`);
|
|
77
101
|
}
|
|
78
102
|
}
|
|
79
|
-
return `${this.metricsCollector
|
|
103
|
+
return `${this.metricsCollector?.prometheus() ?? ""}${gauges.join("\n")}
|
|
80
104
|
`;
|
|
81
105
|
}
|
|
82
106
|
listRuns(query) {
|
|
83
|
-
return this.
|
|
107
|
+
return this.gateway.listRuns(query);
|
|
84
108
|
}
|
|
85
109
|
/** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The
|
|
86
|
-
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. */
|
|
87
|
-
workerHealth() {
|
|
88
|
-
return this.engine.workerHealth();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!run) return null;
|
|
93
|
-
const [timeline, children] = await Promise.all([
|
|
94
|
-
this.store.listCheckpoints(runId),
|
|
95
|
-
this.engine.getRunChildren(runId)
|
|
96
|
-
]);
|
|
97
|
-
return {
|
|
98
|
-
run,
|
|
99
|
-
timeline,
|
|
100
|
-
children
|
|
101
|
-
};
|
|
110
|
+
* alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. Control-plane-only. */
|
|
111
|
+
async workerHealth() {
|
|
112
|
+
return this.controlPlane().engine.workerHealth();
|
|
113
|
+
}
|
|
114
|
+
getRunDetail(runId) {
|
|
115
|
+
return this.gateway.getRunDetail(runId);
|
|
102
116
|
}
|
|
103
117
|
/** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */
|
|
104
118
|
retryWithInput(runId, input) {
|
|
105
|
-
return this.
|
|
119
|
+
return this.gateway.retryWithInput(runId, input);
|
|
106
120
|
}
|
|
107
121
|
/** Re-run a failed/incomplete run; completed steps replay from their checkpoints. */
|
|
108
122
|
retry(runId) {
|
|
109
|
-
return this.
|
|
123
|
+
return this.gateway.retry(runId);
|
|
110
124
|
}
|
|
111
125
|
cancel(runId, opts) {
|
|
112
|
-
return this.
|
|
126
|
+
return this.gateway.cancel(runId, opts);
|
|
113
127
|
}
|
|
114
128
|
/**
|
|
115
129
|
* Apply `retry` or `cancel` to every run matching a filter (status / tag / workflow) — e.g. "retry
|
|
@@ -117,15 +131,15 @@ var DashboardService = class {
|
|
|
117
131
|
* (already terminal) are skipped. Returns how many matched and how many the action applied to.
|
|
118
132
|
*/
|
|
119
133
|
async bulk(action, filter, opts) {
|
|
120
|
-
const runs = await this.
|
|
134
|
+
const runs = await this.gateway.listRuns({
|
|
121
135
|
...filter,
|
|
122
136
|
limit: 500
|
|
123
137
|
});
|
|
124
138
|
let applied = 0;
|
|
125
139
|
for (const r of runs) {
|
|
126
140
|
try {
|
|
127
|
-
if (action === "retry") await this.
|
|
128
|
-
else await this.
|
|
141
|
+
if (action === "retry") await this.gateway.retry(r.id);
|
|
142
|
+
else await this.gateway.cancel(r.id, opts);
|
|
129
143
|
applied += 1;
|
|
130
144
|
} catch {
|
|
131
145
|
}
|
|
@@ -137,45 +151,48 @@ var DashboardService = class {
|
|
|
137
151
|
}
|
|
138
152
|
/** Resume a run paused at a `ctx.breakpoint` (the "continue" button). */
|
|
139
153
|
continue(runId) {
|
|
140
|
-
return this.
|
|
154
|
+
return this.gateway.continue(runId);
|
|
141
155
|
}
|
|
142
156
|
/**
|
|
143
157
|
* Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the
|
|
144
158
|
* waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a
|
|
145
|
-
* stale/duplicate callback) — a safe no-op the controller maps to 404.
|
|
159
|
+
* stale/duplicate callback) — a safe no-op the controller maps to 404. Control-plane-only.
|
|
146
160
|
*/
|
|
147
|
-
deliverWebhook(token, body) {
|
|
148
|
-
return this.engine.signal(token, body);
|
|
161
|
+
async deliverWebhook(token, body) {
|
|
162
|
+
return this.controlPlane().engine.signal(token, body);
|
|
149
163
|
}
|
|
150
|
-
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). */
|
|
151
|
-
getEvent(runId, key) {
|
|
152
|
-
return this.engine.getEvent(runId, key);
|
|
164
|
+
/** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). Control-plane-only. */
|
|
165
|
+
async getEvent(runId, key) {
|
|
166
|
+
return this.controlPlane().engine.getEvent(runId, key);
|
|
153
167
|
}
|
|
154
|
-
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). */
|
|
155
|
-
update(runId, name, arg) {
|
|
156
|
-
return this.engine.update(runId, name, arg);
|
|
168
|
+
/** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). Control-plane-only. */
|
|
169
|
+
async update(runId, name, arg) {
|
|
170
|
+
return this.controlPlane().engine.update(runId, name, arg);
|
|
157
171
|
}
|
|
158
172
|
/**
|
|
159
|
-
* Live stream of a run's lifecycle events for SSE. Backed by `
|
|
160
|
-
*
|
|
161
|
-
*
|
|
173
|
+
* Live stream of a run's lifecycle events for SSE. Backed by `gateway.subscribe`, which — on the
|
|
174
|
+
* control plane — receives events from EVERY instance (so a dashboard-only pod tails a run
|
|
175
|
+
* executing on a worker pod), and on a tenant round-trips over the transport. Either way the
|
|
176
|
+
* gateway already filters to `runId`; the dashboard just wires the events through.
|
|
162
177
|
*/
|
|
163
178
|
streamRun(runId) {
|
|
164
179
|
return new Observable((subscriber) => {
|
|
165
|
-
const off = this.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
});
|
|
169
|
-
});
|
|
180
|
+
const off = this.gateway.subscribe(runId, (event) => subscriber.next({
|
|
181
|
+
data: event
|
|
182
|
+
}));
|
|
170
183
|
return () => off();
|
|
171
184
|
});
|
|
172
185
|
}
|
|
173
186
|
};
|
|
174
187
|
DashboardService = _ts_decorate([
|
|
175
188
|
Injectable(),
|
|
176
|
-
_ts_param(0, Inject(
|
|
189
|
+
_ts_param(0, Inject(RUN_GATEWAY)),
|
|
190
|
+
_ts_param(1, Optional()),
|
|
191
|
+
_ts_param(1, Inject(STATE_STORE_CANONICAL)),
|
|
192
|
+
_ts_param(2, Optional()),
|
|
177
193
|
_ts_metadata("design:type", Function),
|
|
178
194
|
_ts_metadata("design:paramtypes", [
|
|
195
|
+
typeof RunGateway === "undefined" ? Object : RunGateway,
|
|
179
196
|
typeof StateStore === "undefined" ? Object : StateStore,
|
|
180
197
|
typeof WorkflowEngine === "undefined" ? Object : WorkflowEngine
|
|
181
198
|
])
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/dashboard.service.ts","../../src/server/durable-api.controller.ts","../../src/server/attr-filter.ts","../../src/server/durable-dashboard.module.ts","../../src/server/durable-ui.controller.ts"],"sourcesContent":["import {\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type StepCheckpoint,\n type UpdateResult,\n WorkflowEngine,\n type WorkflowRun,\n collectMetrics,\n} from '@dudousxd/nestjs-durable-core';\nimport { Inject, Injectable } from '@nestjs/common';\nimport { Observable } from 'rxjs';\n\nexport interface RunDetail {\n run: WorkflowRun;\n /** Steps in execution order — the end-to-end timeline (local + remote). */\n timeline: StepCheckpoint[];\n /** Ids of runs this run spawned (ctx.child / ctx.startChild) — the parent→children tree. */\n children: string[];\n}\n\n/** Read-model and actions backing the control-plane UI. */\n@Injectable()\nexport class DashboardService {\n /** Prometheus counters accumulated from engine events since boot (per process). */\n private readonly metricsCollector: MetricsCollector;\n\n constructor(\n @Inject(STATE_STORE_CANONICAL) private readonly store: StateStore,\n private readonly engine: WorkflowEngine,\n ) {\n this.metricsCollector = collectMetrics(this.engine);\n }\n\n /**\n * Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,\n * per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`\n * (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ\n * size). Capped per status so the scrape can't load an unbounded result set.\n */\n async metrics(): Promise<string> {\n const cap = 10_000;\n const [pending, running, dead] = await Promise.all([\n this.store.listRuns({ status: 'pending', limit: cap }),\n this.store.listRuns({ status: 'running', limit: cap }),\n this.store.listRuns({ status: 'dead', limit: cap }),\n ]);\n const gauges = [\n '# TYPE durable_pending_runs gauge',\n `durable_pending_runs ${pending.length}`,\n '# TYPE durable_running_runs gauge',\n `durable_running_runs ${running.length}`,\n '# TYPE durable_dead_runs gauge',\n `durable_dead_runs ${dead.length}`,\n ];\n // Per-group worker health: backlog vs. live workers. `depth>0 && live==0` is the alert (work\n // piling up with no consumer) — expressible as a Prometheus rule on these two series.\n const health = await this.engine.workerHealth();\n if (health.length > 0) {\n gauges.push('# TYPE durable_group_queue_depth gauge');\n for (const h of health) {\n gauges.push(`durable_group_queue_depth{group=\"${h.group}\"} ${h.depth}`);\n }\n gauges.push('# TYPE durable_group_live_workers gauge');\n for (const h of health) {\n gauges.push(`durable_group_live_workers{group=\"${h.group}\"} ${h.liveWorkers.length}`);\n }\n }\n return `${this.metricsCollector.prometheus()}${gauges.join('\\n')}\\n`;\n }\n\n listRuns(query: RunQuery): Promise<WorkflowRun[]> {\n return this.store.listRuns(query);\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The\n * alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. */\n workerHealth(): Promise<GroupHealth[]> {\n return this.engine.workerHealth();\n }\n\n async getRunDetail(runId: string): Promise<RunDetail | null> {\n const run = await this.store.getRun(runId);\n if (!run) return null;\n const [timeline, children] = await Promise.all([\n this.store.listCheckpoints(runId),\n this.engine.getRunChildren(runId), // canonical parent→children edge (shared with cancel cascade)\n ]);\n return { run, timeline, children };\n }\n\n /** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */\n retryWithInput(runId: string, input: unknown): Promise<{ runId: string } | null> {\n return this.engine.retryWithInput(runId, input);\n }\n\n /** Re-run a failed/incomplete run; completed steps replay from their checkpoints. */\n retry(runId: string): Promise<RunResult | null> {\n // Re-enqueue (dispatch model) instead of resuming inline, so the request can't hang on workflow\n // execution — a worker picks the run up and replays it.\n return this.engine.requeue(runId);\n }\n\n cancel(runId: string, opts?: { compensate?: boolean }): Promise<RunResult | null> {\n return this.engine.cancel(runId, opts);\n }\n\n /**\n * Apply `retry` or `cancel` to every run matching a filter (status / tag / workflow) — e.g. \"retry\n * every `dead` run tagged `type:mel`\". Capped at 500 runs per call; runs that can't be acted on\n * (already terminal) are skipped. Returns how many matched and how many the action applied to.\n */\n async bulk(\n action: 'retry' | 'cancel',\n filter: Pick<RunQuery, 'status' | 'tag' | 'workflow' | 'attributes'>,\n opts?: { compensate?: boolean },\n ): Promise<{ matched: number; applied: number }> {\n const runs = await this.store.listRuns({ ...filter, limit: 500 });\n let applied = 0;\n for (const r of runs) {\n try {\n if (action === 'retry') await this.engine.requeue(r.id);\n else await this.engine.cancel(r.id, opts);\n applied += 1;\n } catch {\n // Skip a run that can't take the action (e.g. already terminal) — keep going.\n }\n }\n return { matched: runs.length, applied };\n }\n\n /** Resume a run paused at a `ctx.breakpoint` (the \"continue\" button). */\n continue(runId: string): Promise<RunResult | null> {\n return this.engine.continue(runId);\n }\n\n /**\n * Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the\n * waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a\n * stale/duplicate callback) — a safe no-op the controller maps to 404.\n */\n deliverWebhook(token: string, body: unknown): Promise<RunResult | null> {\n return this.engine.signal(token, body);\n }\n\n /** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). */\n getEvent(runId: string, key: string): Promise<unknown> {\n return this.engine.getEvent(runId, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). */\n update(runId: string, name: string, arg: unknown): Promise<UpdateResult> {\n return this.engine.update(runId, name, arg);\n }\n\n /**\n * Live stream of a run's lifecycle events for SSE. Backed by `engine.subscribe`, which — when the\n * transport has a control plane — receives events from EVERY instance, so a dashboard-only pod\n * tails a run executing on a worker pod. Without a control plane it only sees same-process events.\n */\n streamRun(runId: string): Observable<{ data: EngineEvent }> {\n return new Observable<{ data: EngineEvent }>((subscriber) => {\n const off = this.engine.subscribe((event) => {\n if (event.runId === runId) subscriber.next({ data: event });\n });\n return () => off();\n });\n }\n}\n","import type { RunStatus } from '@dudousxd/nestjs-durable-core';\nimport {\n Body,\n Controller,\n Get,\n Header,\n NotFoundException,\n Param,\n Post,\n Query,\n Sse,\n} from '@nestjs/common';\nimport { parseAttrFilters } from './attr-filter.js';\nimport { DashboardService } from './dashboard.service.js';\n\n/** JSON API consumed by the control-plane SPA. Mounted at `apiBasePath` (set by RouterModule). */\n@Controller()\nexport class DurableApiController {\n constructor(private readonly dashboard: DashboardService) {}\n\n @Get('runs')\n runs(\n @Query('status') status?: RunStatus,\n @Query('workflow') workflow?: string,\n @Query('tag') tag?: string,\n @Query('attr') attr?: string | string[],\n ) {\n return this.dashboard.listRuns({ status, workflow, tag, attributes: parseAttrFilters(attr) });\n }\n\n /** Prometheus-text metrics (runs/steps by outcome, per-workflow counts) for a scrape. */\n @Get('metrics')\n @Header('Content-Type', 'text/plain; version=0.0.4')\n metrics() {\n return this.dashboard.metrics();\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. */\n @Get('workers')\n workers() {\n return this.dashboard.workerHealth();\n }\n\n @Get('runs/:id')\n async run(@Param('id') id: string) {\n const detail = await this.dashboard.getRunDetail(id);\n if (!detail) throw new NotFoundException(`run ${id} not found`);\n return detail;\n }\n\n /** Server-Sent Events stream of a run's live lifecycle events — the dashboard tails it instead\n * of polling. Cross-pod when the transport has a control plane (see DashboardService.streamRun). */\n @Sse('runs/:id/stream')\n stream(@Param('id') id: string) {\n return this.dashboard.streamRun(id);\n }\n\n @Post('runs/:id/retry')\n retry(@Param('id') id: string) {\n return this.dashboard.retry(id);\n }\n\n /** Fix-and-replay: re-run a dead/failed run with a corrected input (a fresh linked run). */\n @Post('runs/:id/retry-with-input')\n retryWithInput(@Param('id') id: string, @Body() body: { input: unknown }) {\n return this.dashboard.retryWithInput(id, body?.input);\n }\n\n /** Bulk retry/cancel every run matching a filter (status / tag / workflow). */\n @Post('bulk/:action')\n bulk(\n @Param('action') action: 'retry' | 'cancel',\n @Query('status') status?: RunStatus,\n @Query('tag') tag?: string,\n @Query('workflow') workflow?: string,\n @Query('attr') attr?: string | string[],\n @Query('compensate') compensate?: string,\n ) {\n return this.dashboard.bulk(\n action === 'cancel' ? 'cancel' : 'retry',\n { status, tag, workflow, attributes: parseAttrFilters(attr) },\n { compensate: compensate === 'true' },\n );\n }\n\n @Post('runs/:id/cancel')\n async cancel(@Param('id') id: string, @Query('compensate') compensate?: string) {\n const result = await this.dashboard.cancel(id, { compensate: compensate === 'true' });\n if (!result) throw new NotFoundException(`run ${id} not found`);\n return result;\n }\n\n @Post('runs/:id/continue')\n async continue(@Param('id') id: string) {\n const result = await this.dashboard.continue(id);\n if (!result) throw new NotFoundException(`run ${id} is not paused at a breakpoint`);\n return result;\n }\n\n /**\n * Public callback endpoint for `ctx.webhook()`: a third party POSTs here (the url handed to it),\n * and the body resumes the waiting run. NOTE: this is reachable by external systems — protect it\n * by treating the token as a secret (it embeds runId:seq) and/or fronting it with signature\n * verification in your own middleware.\n */\n @Post('webhooks/:token')\n async webhook(@Param('token') token: string, @Body() body: unknown) {\n const result = await this.dashboard.deliverWebhook(token, body);\n if (!result) throw new NotFoundException(`no run is waiting on webhook ${token}`);\n return result;\n }\n\n /** Live query: the latest value a run published for `key` via `ctx.setEvent` (no side effect). */\n @Get('runs/:id/events/:key')\n event(@Param('id') id: string, @Param('key') key: string) {\n return this.dashboard.getEvent(id, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the body is the update argument. */\n @Post('runs/:id/updates/:name')\n update(@Param('id') id: string, @Param('name') name: string, @Body() body: unknown) {\n return this.dashboard.update(id, name, body);\n }\n}\n","import type { AttributeFilter, AttributeOp } from '@dudousxd/nestjs-durable-core';\n\nconst ATTR_OPS = new Set<AttributeOp>(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);\n\n/** Coerce a query-string value: `true`/`false` → boolean, numeric → number, else the raw string. */\nfunction coerce(v: string): string | number | boolean {\n if (v === 'true') return true;\n if (v === 'false') return false;\n if (v !== '' && !Number.isNaN(Number(v))) return Number(v);\n return v;\n}\n\n/**\n * Parse `attr=key:op:value` query params (repeatable, ANDed) into {@link AttributeFilter}s — e.g.\n * `?attr=amount:gte:200&attr=tier:eq:pro`. `op` must be a known operator; a colon in the value is\n * preserved (only the first two colons delimit). Malformed entries are skipped.\n */\nexport function parseAttrFilters(attr?: string | string[]): AttributeFilter[] | undefined {\n if (!attr) return undefined;\n const raw = Array.isArray(attr) ? attr : [attr];\n const filters: AttributeFilter[] = [];\n for (const entry of raw) {\n const [key, op, ...rest] = entry.split(':');\n if (!key || !ATTR_OPS.has(op as AttributeOp) || rest.length === 0) continue;\n filters.push({ key, op: op as AttributeOp, value: coerce(rest.join(':')) });\n }\n return filters.length ? filters : undefined;\n}\n","import { type DynamicModule, Module } from '@nestjs/common';\nimport { RouterModule } from '@nestjs/core';\nimport { DashboardService } from './dashboard.service.js';\nimport { DurableApiController } from './durable-api.controller.js';\nimport {\n DASHBOARD_API_PATH,\n DASHBOARD_BASE_PATH,\n DurableUiController,\n} from './durable-ui.controller.js';\n\nexport interface DurableDashboardOptions {\n /**\n * Where the SPA (UI) is served. Default `/durable`. This is a page route — keep it out of an\n * `/api` prefix so it reads as a UI, not an endpoint.\n */\n basePath?: string;\n /**\n * Where the JSON API is mounted (what the SPA fetches). Default `<basePath>/api`. Set it under\n * your app's `/api` prefix — e.g. `/api/durable` — so the API inherits the app's auth/proxy rules\n * while the UI stays at `basePath`.\n */\n apiBasePath?: string;\n}\n\n/** Leading slash, no trailing slash. */\nfunction normalize(path: string): string {\n return `/${path.replace(/^\\/+|\\/+$/g, '')}`;\n}\n\n/** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */\n@Module({\n controllers: [DurableApiController],\n providers: [DashboardService],\n exports: [DashboardService],\n})\nexport class DurableApiModule {}\n\n/**\n * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`\n * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside\n * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.\n */\n@Module({})\nexport class DurableDashboardModule {\n static forRoot(options: DurableDashboardOptions = {}): DynamicModule {\n const basePath = normalize(options.basePath ?? '/durable');\n const apiBasePath = normalize(options.apiBasePath ?? `${basePath}/api`);\n return {\n module: DurableDashboardModule,\n imports: [\n DurableApiModule,\n RouterModule.register([\n { path: basePath, module: DurableDashboardModule }, // the UI controller below\n { path: apiBasePath, module: DurableApiModule },\n ]),\n ],\n controllers: [DurableUiController],\n providers: [\n { provide: DASHBOARD_BASE_PATH, useValue: basePath },\n { provide: DASHBOARD_API_PATH, useValue: apiBasePath },\n ],\n // Re-export the API module so its DashboardService reaches importers (e.g. flip's own controllers).\n exports: [DurableApiModule],\n };\n }\n}\n","import { existsSync, readFileSync } from 'node:fs';\nimport { basename, extname, join, resolve, sep } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport {\n Controller,\n Get,\n Header,\n Inject,\n NotFoundException,\n Param,\n StreamableFile,\n} from '@nestjs/common';\n\n/** DI token carrying the UI mount base (e.g. `/durable`). */\nexport const DASHBOARD_BASE_PATH = Symbol('DASHBOARD_BASE_PATH');\n/** DI token carrying the JSON API base the SPA fetches from (e.g. `/api/durable`). */\nexport const DASHBOARD_API_PATH = Symbol('DASHBOARD_API_PATH');\n\n/** The base the SPA bundle was built with (Vite `base`); rewritten to the configured base at serve time. */\nconst BUILD_BASE = '/durable';\n\n/** dist/server/durable-ui.controller.js -> ../spa (the Vite build output). */\nfunction spaDir(): string {\n return fileURLToPath(new URL('../spa', import.meta.url));\n}\n\nconst CONTENT_TYPES: Record<string, string> = {\n '.js': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.map': 'application/json; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.json': 'application/json; charset=utf-8',\n '.woff2': 'font/woff2',\n '.ico': 'image/x-icon',\n};\n\n/**\n * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).\n * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the\n * controller routes are relative.\n */\n@Controller()\nexport class DurableUiController {\n private readonly dir = spaDir();\n\n constructor(\n @Inject(DASHBOARD_BASE_PATH) private readonly basePath: string,\n @Inject(DASHBOARD_API_PATH) private readonly apiBasePath: string,\n ) {}\n\n // index.html references hash-named bundles, so it MUST NOT be cached (stale bundle = the\n // classic \"stuck loading after a deploy\"). The hashed assets below are immutable.\n @Get()\n @Header('Content-Type', 'text/html; charset=utf-8')\n @Header('Cache-Control', 'no-store, must-revalidate')\n index(): string {\n const indexPath = join(this.dir, 'index.html');\n if (!existsSync(indexPath)) {\n throw new NotFoundException('Dashboard is not built. Run the package build.');\n }\n // The bundle was built with Vite base `/durable/`; rewrite asset URLs to the configured base so\n // the SPA loads from `<base>/assets` wherever it's mounted, and tell the client its API base.\n const html = readFileSync(indexPath, 'utf8').replaceAll(\n `=\"${BUILD_BASE}/`,\n `=\"${this.basePath}/`,\n );\n // __DURABLE_BASE__ = where assets load; __DURABLE_API__ = where the SPA fetches the JSON API.\n const inject = `<script>window.__DURABLE_BASE__='${this.basePath}';window.__DURABLE_API__='${this.apiBasePath}';</script>`;\n return html.includes('</head>') ? html.replace('</head>', `${inject}</head>`) : inject + html;\n }\n\n @Get('assets/:file')\n @Header('Cache-Control', 'public, max-age=31536000, immutable')\n asset(@Param('file') file: string): StreamableFile {\n const safe = basename(file);\n if (safe !== file) throw new NotFoundException();\n const root = resolve(this.dir, 'assets');\n const assetPath = resolve(root, safe);\n if (!assetPath.startsWith(root + sep) || !existsSync(assetPath)) {\n throw new NotFoundException();\n }\n const type = CONTENT_TYPES[extname(safe)] ?? 'application/octet-stream';\n return new StreamableFile(readFileSync(assetPath), { type });\n }\n}\n"],"mappings":"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","names":["STATE_STORE_CANONICAL","WorkflowEngine","collectMetrics","Inject","Injectable","Observable","DashboardService","metricsCollector","store","engine","collectMetrics","metrics","cap","pending","running","dead","Promise","all","listRuns","status","limit","gauges","length","health","workerHealth","push","h","group","depth","liveWorkers","prometheus","join","query","getRunDetail","runId","run","getRun","timeline","children","listCheckpoints","getRunChildren","retryWithInput","input","retry","requeue","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","deliverWebhook","token","body","signal","getEvent","key","update","name","arg","streamRun","Observable","subscriber","off","subscribe","event","next","data","Body","Controller","Get","Header","NotFoundException","Param","Post","Query","Sse","ATTR_OPS","Set","coerce","v","Number","isNaN","parseAttrFilters","attr","undefined","raw","Array","isArray","filters","entry","key","op","rest","split","has","length","push","value","join","DurableApiController","dashboard","runs","status","workflow","tag","attr","listRuns","attributes","parseAttrFilters","metrics","workers","workerHealth","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","webhook","token","deliverWebhook","event","key","getEvent","update","name","Module","RouterModule","existsSync","readFileSync","basename","extname","join","resolve","sep","fileURLToPath","Controller","Get","Header","Inject","NotFoundException","Param","StreamableFile","DASHBOARD_BASE_PATH","Symbol","DASHBOARD_API_PATH","BUILD_BASE","spaDir","fileURLToPath","URL","url","CONTENT_TYPES","DurableUiController","dir","basePath","apiBasePath","index","indexPath","join","existsSync","NotFoundException","html","readFileSync","replaceAll","inject","includes","replace","asset","file","safe","basename","root","resolve","assetPath","startsWith","sep","type","extname","StreamableFile","normalize","path","replace","DurableApiModule","controllers","DurableApiController","providers","DashboardService","exports","DurableDashboardModule","forRoot","options","basePath","apiBasePath","module","imports","RouterModule","register","DurableUiController","provide","DASHBOARD_BASE_PATH","useValue","DASHBOARD_API_PATH"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/dashboard.service.ts","../../src/server/tokens.ts","../../src/server/durable-api.controller.ts","../../src/server/attr-filter.ts","../../src/server/durable-dashboard.module.ts","../../src/server/durable-ui.controller.ts"],"sourcesContent":["import {\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunGateway,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type StepCheckpoint,\n type UpdateResult,\n WorkflowEngine,\n type WorkflowRun,\n collectMetrics,\n} from '@dudousxd/nestjs-durable-core';\nimport { Inject, Injectable, Optional } from '@nestjs/common';\nimport { Observable } from 'rxjs';\nimport { RUN_GATEWAY } from './tokens.js';\n\nexport interface RunDetail {\n run: WorkflowRun;\n /** Steps in execution order — the end-to-end timeline (local + remote). */\n timeline: StepCheckpoint[];\n /** Ids of runs this run spawned (ctx.child / ctx.startChild) — the parent→children tree. */\n children: string[];\n}\n\n/**\n * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/\n * continue/retryWithInput/stream/bulk) route through the injected {@link RUN_GATEWAY} port, which\n * the lib binds on both topologies (a store-backed impl on the control plane, a proxy over the\n * transport on a store-less tenant) — so those ops work regardless of whether `store`/`engine`\n * are present. Operator-only ops (metrics/workerHealth/deliverWebhook/getEvent/update) still need\n * direct `store`/`engine` access; they stay `@Optional()` and throw a clear error when absent.\n */\n@Injectable()\nexport class DashboardService {\n /** Prometheus counters accumulated from engine events since boot (per process); undefined on a\n * store-less tenant (no engine to accumulate from). */\n private readonly metricsCollector: MetricsCollector | undefined;\n\n constructor(\n @Inject(RUN_GATEWAY) private readonly gateway: RunGateway,\n @Optional() @Inject(STATE_STORE_CANONICAL) private readonly store?: StateStore,\n @Optional() private readonly engine?: WorkflowEngine,\n ) {\n // Only the control plane (which has the canonical store) has a FULL engine to accumulate from;\n // on a tenant `this.engine` is a store-less start client with no `subscribe`, so guard on store.\n this.metricsCollector = this.store && this.engine ? collectMetrics(this.engine) : undefined;\n }\n\n /**\n * The operator-only ops need the FULL engine + canonical store, which only the control plane has.\n * On a tenant the `WorkflowEngine` token is a store-less start client that implements none of these\n * methods, so presence of the canonical store is the reliable discriminator — gate on it (never\n * on engine presence, which is truthy on a tenant too).\n */\n private controlPlane(): { store: StateStore; engine: WorkflowEngine } {\n if (!this.store || !this.engine) {\n throw new Error(\n 'This durable dashboard operation requires the control plane (not available on a tenant deployment).',\n );\n }\n return { store: this.store, engine: this.engine };\n }\n\n /**\n * Prometheus-text metrics for a `/metrics` scrape: the event-counters (runs/steps by outcome,\n * per-workflow counts) plus live **backlog gauges** queried at scrape time — `durable_pending_runs`\n * (the dispatch backlog: the key health signal of the dispatch model) and `durable_dead_runs` (DLQ\n * size). Capped per status so the scrape can't load an unbounded result set. Control-plane-only.\n */\n async metrics(): Promise<string> {\n const { store, engine } = this.controlPlane();\n const cap = 10_000;\n const [pending, running, dead] = await Promise.all([\n store.listRuns({ status: 'pending', limit: cap }),\n store.listRuns({ status: 'running', limit: cap }),\n store.listRuns({ status: 'dead', limit: cap }),\n ]);\n const gauges = [\n '# TYPE durable_pending_runs gauge',\n `durable_pending_runs ${pending.length}`,\n '# TYPE durable_running_runs gauge',\n `durable_running_runs ${running.length}`,\n '# TYPE durable_dead_runs gauge',\n `durable_dead_runs ${dead.length}`,\n ];\n // Per-group worker health: backlog vs. live workers. `depth>0 && live==0` is the alert (work\n // piling up with no consumer) — expressible as a Prometheus rule on these two series.\n const health = await engine.workerHealth();\n if (health.length > 0) {\n gauges.push('# TYPE durable_group_queue_depth gauge');\n for (const h of health) {\n gauges.push(`durable_group_queue_depth{group=\"${h.group}\"} ${h.depth}`);\n }\n gauges.push('# TYPE durable_group_live_workers gauge');\n for (const h of health) {\n gauges.push(`durable_group_live_workers{group=\"${h.group}\"} ${h.liveWorkers.length}`);\n }\n }\n return `${this.metricsCollector?.prometheus() ?? ''}${gauges.join('\\n')}\\n`;\n }\n\n listRuns(query: RunQuery): Promise<WorkflowRun[]> {\n return this.gateway.listRuns(query);\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. The\n * alert state a row turns red on is `depth > 0 && liveWorkers.length === 0`. Control-plane-only. */\n async workerHealth(): Promise<GroupHealth[]> {\n return this.controlPlane().engine.workerHealth();\n }\n\n getRunDetail(runId: string): Promise<RunDetail | null> {\n return this.gateway.getRunDetail(runId);\n }\n\n /** Fix-and-replay a dead/failed run with a corrected input — a fresh linked run. Returns its id. */\n retryWithInput(runId: string, input: unknown): Promise<{ runId: string } | null> {\n return this.gateway.retryWithInput(runId, input);\n }\n\n /** Re-run a failed/incomplete run; completed steps replay from their checkpoints. */\n retry(runId: string): Promise<RunResult | null> {\n return this.gateway.retry(runId);\n }\n\n cancel(runId: string, opts?: { compensate?: boolean }): Promise<RunResult | null> {\n return this.gateway.cancel(runId, opts);\n }\n\n /**\n * Apply `retry` or `cancel` to every run matching a filter (status / tag / workflow) — e.g. \"retry\n * every `dead` run tagged `type:mel`\". Capped at 500 runs per call; runs that can't be acted on\n * (already terminal) are skipped. Returns how many matched and how many the action applied to.\n */\n async bulk(\n action: 'retry' | 'cancel',\n filter: Pick<RunQuery, 'status' | 'tag' | 'workflow' | 'attributes'>,\n opts?: { compensate?: boolean },\n ): Promise<{ matched: number; applied: number }> {\n const runs = await this.gateway.listRuns({ ...filter, limit: 500 });\n let applied = 0;\n for (const r of runs) {\n try {\n if (action === 'retry') await this.gateway.retry(r.id);\n else await this.gateway.cancel(r.id, opts);\n applied += 1;\n } catch {\n // Skip a run that can't take the action (e.g. already terminal) — keep going.\n }\n }\n return { matched: runs.length, applied };\n }\n\n /** Resume a run paused at a `ctx.breakpoint` (the \"continue\" button). */\n continue(runId: string): Promise<RunResult | null> {\n return this.gateway.continue(runId);\n }\n\n /**\n * Deliver a `ctx.webhook()` callback: turn an inbound POST (token + body) into the signal the\n * waiting run is parked on. Returns the run result, or `null` if no run waits on that token (a\n * stale/duplicate callback) — a safe no-op the controller maps to 404. Control-plane-only.\n */\n async deliverWebhook(token: string, body: unknown): Promise<RunResult | null> {\n return this.controlPlane().engine.signal(token, body);\n }\n\n /** Side-effect-free read of a value a run published via `ctx.setEvent` (a live query). Control-plane-only. */\n async getEvent(runId: string, key: string): Promise<unknown> {\n return this.controlPlane().engine.getEvent(runId, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the validator may reject it (see UpdateResult). Control-plane-only. */\n async update(runId: string, name: string, arg: unknown): Promise<UpdateResult> {\n return this.controlPlane().engine.update(runId, name, arg);\n }\n\n /**\n * Live stream of a run's lifecycle events for SSE. Backed by `gateway.subscribe`, which — on the\n * control plane — receives events from EVERY instance (so a dashboard-only pod tails a run\n * executing on a worker pod), and on a tenant round-trips over the transport. Either way the\n * gateway already filters to `runId`; the dashboard just wires the events through.\n */\n streamRun(runId: string): Observable<{ data: EngineEvent }> {\n return new Observable<{ data: EngineEvent }>((subscriber) => {\n const off = this.gateway.subscribe(runId, (event) => subscriber.next({ data: event }));\n return () => off();\n });\n }\n}\n","/**\n * Injection token for the bound `RunGateway`, owned by `@dudousxd/nestjs-durable` (the nestjs\n * package). The dashboard package does NOT depend on `@dudousxd/nestjs-durable` — its only\n * relevant peer dep is `@dudousxd/nestjs-durable-core` — so we do NOT import the token from\n * there. Instead we share its well-known token by value, the same pattern\n * `@dudousxd/nestjs-durable`'s own `tokens.ts` uses for `CONTEXT_ACCESSOR` (a cross-lib token\n * owned by `@dudousxd/nestjs-context`).\n *\n * `Symbol.for(key)` uses the global symbol registry, so this resolves to the SAME symbol\n * instance as `packages/nestjs/src/tokens.ts`'s `RUN_GATEWAY` without any import. The key MUST\n * stay byte-identical with that export.\n */\nexport const RUN_GATEWAY = Symbol.for('nestjs-durable:run-gateway');\n","import type { RunStatus } from '@dudousxd/nestjs-durable-core';\nimport {\n Body,\n Controller,\n Get,\n Header,\n NotFoundException,\n Param,\n Post,\n Query,\n Sse,\n} from '@nestjs/common';\nimport { parseAttrFilters } from './attr-filter.js';\nimport { DashboardService } from './dashboard.service.js';\n\n/** JSON API consumed by the control-plane SPA. Mounted at `apiBasePath` (set by RouterModule). */\n@Controller()\nexport class DurableApiController {\n constructor(private readonly dashboard: DashboardService) {}\n\n @Get('runs')\n runs(\n @Query('status') status?: RunStatus,\n @Query('workflow') workflow?: string,\n @Query('tag') tag?: string,\n @Query('attr') attr?: string | string[],\n ) {\n return this.dashboard.listRuns({ status, workflow, tag, attributes: parseAttrFilters(attr) });\n }\n\n /** Prometheus-text metrics (runs/steps by outcome, per-workflow counts) for a scrape. */\n @Get('metrics')\n @Header('Content-Type', 'text/plain; version=0.0.4')\n metrics() {\n return this.dashboard.metrics();\n }\n\n /** Per-group worker health (queue backlog + live worker heartbeats) for the Workers panel. */\n @Get('workers')\n workers() {\n return this.dashboard.workerHealth();\n }\n\n @Get('runs/:id')\n async run(@Param('id') id: string) {\n const detail = await this.dashboard.getRunDetail(id);\n if (!detail) throw new NotFoundException(`run ${id} not found`);\n return detail;\n }\n\n /** Server-Sent Events stream of a run's live lifecycle events — the dashboard tails it instead\n * of polling. Cross-pod when the transport has a control plane (see DashboardService.streamRun). */\n @Sse('runs/:id/stream')\n stream(@Param('id') id: string) {\n return this.dashboard.streamRun(id);\n }\n\n @Post('runs/:id/retry')\n retry(@Param('id') id: string) {\n return this.dashboard.retry(id);\n }\n\n /** Fix-and-replay: re-run a dead/failed run with a corrected input (a fresh linked run). */\n @Post('runs/:id/retry-with-input')\n retryWithInput(@Param('id') id: string, @Body() body: { input: unknown }) {\n return this.dashboard.retryWithInput(id, body?.input);\n }\n\n /** Bulk retry/cancel every run matching a filter (status / tag / workflow). */\n @Post('bulk/:action')\n bulk(\n @Param('action') action: 'retry' | 'cancel',\n @Query('status') status?: RunStatus,\n @Query('tag') tag?: string,\n @Query('workflow') workflow?: string,\n @Query('attr') attr?: string | string[],\n @Query('compensate') compensate?: string,\n ) {\n return this.dashboard.bulk(\n action === 'cancel' ? 'cancel' : 'retry',\n { status, tag, workflow, attributes: parseAttrFilters(attr) },\n { compensate: compensate === 'true' },\n );\n }\n\n @Post('runs/:id/cancel')\n async cancel(@Param('id') id: string, @Query('compensate') compensate?: string) {\n const result = await this.dashboard.cancel(id, { compensate: compensate === 'true' });\n if (!result) throw new NotFoundException(`run ${id} not found`);\n return result;\n }\n\n @Post('runs/:id/continue')\n async continue(@Param('id') id: string) {\n const result = await this.dashboard.continue(id);\n if (!result) throw new NotFoundException(`run ${id} is not paused at a breakpoint`);\n return result;\n }\n\n /**\n * Public callback endpoint for `ctx.webhook()`: a third party POSTs here (the url handed to it),\n * and the body resumes the waiting run. NOTE: this is reachable by external systems — protect it\n * by treating the token as a secret (it embeds runId:seq) and/or fronting it with signature\n * verification in your own middleware.\n */\n @Post('webhooks/:token')\n async webhook(@Param('token') token: string, @Body() body: unknown) {\n const result = await this.dashboard.deliverWebhook(token, body);\n if (!result) throw new NotFoundException(`no run is waiting on webhook ${token}`);\n return result;\n }\n\n /** Live query: the latest value a run published for `key` via `ctx.setEvent` (no side effect). */\n @Get('runs/:id/events/:key')\n event(@Param('id') id: string, @Param('key') key: string) {\n return this.dashboard.getEvent(id, key);\n }\n\n /** Deliver a validated `ctx.onUpdate` to a run; the body is the update argument. */\n @Post('runs/:id/updates/:name')\n update(@Param('id') id: string, @Param('name') name: string, @Body() body: unknown) {\n return this.dashboard.update(id, name, body);\n }\n}\n","import type { AttributeFilter, AttributeOp } from '@dudousxd/nestjs-durable-core';\n\nconst ATTR_OPS = new Set<AttributeOp>(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);\n\n/** Coerce a query-string value: `true`/`false` → boolean, numeric → number, else the raw string. */\nfunction coerce(v: string): string | number | boolean {\n if (v === 'true') return true;\n if (v === 'false') return false;\n if (v !== '' && !Number.isNaN(Number(v))) return Number(v);\n return v;\n}\n\n/**\n * Parse `attr=key:op:value` query params (repeatable, ANDed) into {@link AttributeFilter}s — e.g.\n * `?attr=amount:gte:200&attr=tier:eq:pro`. `op` must be a known operator; a colon in the value is\n * preserved (only the first two colons delimit). Malformed entries are skipped.\n */\nexport function parseAttrFilters(attr?: string | string[]): AttributeFilter[] | undefined {\n if (!attr) return undefined;\n const raw = Array.isArray(attr) ? attr : [attr];\n const filters: AttributeFilter[] = [];\n for (const entry of raw) {\n const [key, op, ...rest] = entry.split(':');\n if (!key || !ATTR_OPS.has(op as AttributeOp) || rest.length === 0) continue;\n filters.push({ key, op: op as AttributeOp, value: coerce(rest.join(':')) });\n }\n return filters.length ? filters : undefined;\n}\n","import { type DynamicModule, Module } from '@nestjs/common';\nimport { RouterModule } from '@nestjs/core';\nimport { DashboardService } from './dashboard.service.js';\nimport { DurableApiController } from './durable-api.controller.js';\nimport {\n DASHBOARD_API_PATH,\n DASHBOARD_BASE_PATH,\n DurableUiController,\n} from './durable-ui.controller.js';\n\nexport interface DurableDashboardOptions {\n /**\n * Where the SPA (UI) is served. Default `/durable`. This is a page route — keep it out of an\n * `/api` prefix so it reads as a UI, not an endpoint.\n */\n basePath?: string;\n /**\n * Where the JSON API is mounted (what the SPA fetches). Default `<basePath>/api`. Set it under\n * your app's `/api` prefix — e.g. `/api/durable` — so the API inherits the app's auth/proxy rules\n * while the UI stays at `basePath`.\n */\n apiBasePath?: string;\n}\n\n/** Leading slash, no trailing slash. */\nfunction normalize(path: string): string {\n return `/${path.replace(/^\\/+|\\/+$/g, '')}`;\n}\n\n/** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */\n@Module({\n controllers: [DurableApiController],\n providers: [DashboardService],\n exports: [DashboardService],\n})\nexport class DurableApiModule {}\n\n/**\n * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`\n * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside\n * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.\n */\n@Module({})\nexport class DurableDashboardModule {\n static forRoot(options: DurableDashboardOptions = {}): DynamicModule {\n const basePath = normalize(options.basePath ?? '/durable');\n const apiBasePath = normalize(options.apiBasePath ?? `${basePath}/api`);\n return {\n module: DurableDashboardModule,\n imports: [\n DurableApiModule,\n RouterModule.register([\n { path: basePath, module: DurableDashboardModule }, // the UI controller below\n { path: apiBasePath, module: DurableApiModule },\n ]),\n ],\n controllers: [DurableUiController],\n providers: [\n { provide: DASHBOARD_BASE_PATH, useValue: basePath },\n { provide: DASHBOARD_API_PATH, useValue: apiBasePath },\n ],\n // Re-export the API module so its DashboardService reaches importers (e.g. flip's own controllers).\n exports: [DurableApiModule],\n };\n }\n}\n","import { existsSync, readFileSync } from 'node:fs';\nimport { basename, extname, join, resolve, sep } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport {\n Controller,\n Get,\n Header,\n Inject,\n NotFoundException,\n Param,\n StreamableFile,\n} from '@nestjs/common';\n\n/** DI token carrying the UI mount base (e.g. `/durable`). */\nexport const DASHBOARD_BASE_PATH = Symbol('DASHBOARD_BASE_PATH');\n/** DI token carrying the JSON API base the SPA fetches from (e.g. `/api/durable`). */\nexport const DASHBOARD_API_PATH = Symbol('DASHBOARD_API_PATH');\n\n/** The base the SPA bundle was built with (Vite `base`); rewritten to the configured base at serve time. */\nconst BUILD_BASE = '/durable';\n\n/** dist/server/durable-ui.controller.js -> ../spa (the Vite build output). */\nfunction spaDir(): string {\n return fileURLToPath(new URL('../spa', import.meta.url));\n}\n\nconst CONTENT_TYPES: Record<string, string> = {\n '.js': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.map': 'application/json; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.json': 'application/json; charset=utf-8',\n '.woff2': 'font/woff2',\n '.ico': 'image/x-icon',\n};\n\n/**\n * Serves the bundled control-plane SPA at the configured base (+ hashed assets at `<base>/assets`).\n * The path comes from `RouterModule` (set by `DurableDashboardModule.forRoot({ basePath })`), so the\n * controller routes are relative.\n */\n@Controller()\nexport class DurableUiController {\n private readonly dir = spaDir();\n\n constructor(\n @Inject(DASHBOARD_BASE_PATH) private readonly basePath: string,\n @Inject(DASHBOARD_API_PATH) private readonly apiBasePath: string,\n ) {}\n\n // index.html references hash-named bundles, so it MUST NOT be cached (stale bundle = the\n // classic \"stuck loading after a deploy\"). The hashed assets below are immutable.\n @Get()\n @Header('Content-Type', 'text/html; charset=utf-8')\n @Header('Cache-Control', 'no-store, must-revalidate')\n index(): string {\n const indexPath = join(this.dir, 'index.html');\n if (!existsSync(indexPath)) {\n throw new NotFoundException('Dashboard is not built. Run the package build.');\n }\n // The bundle was built with Vite base `/durable/`; rewrite asset URLs to the configured base so\n // the SPA loads from `<base>/assets` wherever it's mounted, and tell the client its API base.\n const html = readFileSync(indexPath, 'utf8').replaceAll(\n `=\"${BUILD_BASE}/`,\n `=\"${this.basePath}/`,\n );\n // __DURABLE_BASE__ = where assets load; __DURABLE_API__ = where the SPA fetches the JSON API.\n const inject = `<script>window.__DURABLE_BASE__='${this.basePath}';window.__DURABLE_API__='${this.apiBasePath}';</script>`;\n return html.includes('</head>') ? html.replace('</head>', `${inject}</head>`) : inject + html;\n }\n\n @Get('assets/:file')\n @Header('Cache-Control', 'public, max-age=31536000, immutable')\n asset(@Param('file') file: string): StreamableFile {\n const safe = basename(file);\n if (safe !== file) throw new NotFoundException();\n const root = resolve(this.dir, 'assets');\n const assetPath = resolve(root, safe);\n if (!assetPath.startsWith(root + sep) || !existsSync(assetPath)) {\n throw new NotFoundException();\n }\n const type = CONTENT_TYPES[extname(safe)] ?? 'application/octet-stream';\n return new StreamableFile(readFileSync(assetPath), { type });\n }\n}\n"],"mappings":"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","names":["STATE_STORE_CANONICAL","WorkflowEngine","collectMetrics","Inject","Injectable","Optional","Observable","RUN_GATEWAY","Symbol","for","DashboardService","metricsCollector","gateway","store","engine","collectMetrics","undefined","controlPlane","Error","metrics","cap","pending","running","dead","Promise","all","listRuns","status","limit","gauges","length","health","workerHealth","push","h","group","depth","liveWorkers","prometheus","join","query","getRunDetail","runId","retryWithInput","input","retry","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","deliverWebhook","token","body","signal","getEvent","key","update","name","arg","streamRun","Observable","subscriber","off","subscribe","event","next","data","Body","Controller","Get","Header","NotFoundException","Param","Post","Query","Sse","ATTR_OPS","Set","coerce","v","Number","isNaN","parseAttrFilters","attr","undefined","raw","Array","isArray","filters","entry","key","op","rest","split","has","length","push","value","join","DurableApiController","dashboard","runs","status","workflow","tag","attr","listRuns","attributes","parseAttrFilters","metrics","workers","workerHealth","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","webhook","token","deliverWebhook","event","key","getEvent","update","name","Module","RouterModule","existsSync","readFileSync","basename","extname","join","resolve","sep","fileURLToPath","Controller","Get","Header","Inject","NotFoundException","Param","StreamableFile","DASHBOARD_BASE_PATH","Symbol","DASHBOARD_API_PATH","BUILD_BASE","spaDir","fileURLToPath","URL","url","CONTENT_TYPES","DurableUiController","dir","basePath","apiBasePath","index","indexPath","join","existsSync","NotFoundException","html","readFileSync","replaceAll","inject","includes","replace","asset","file","safe","basename","root","resolve","assetPath","startsWith","sep","type","extname","StreamableFile","normalize","path","replace","DurableApiModule","controllers","DurableApiController","providers","DashboardService","exports","DurableDashboardModule","forRoot","options","basePath","apiBasePath","module","imports","RouterModule","register","DurableUiController","provide","DASHBOARD_BASE_PATH","useValue","DASHBOARD_API_PATH"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dudousxd/nestjs-durable-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Embedded control plane (runs, step timeline, retry/cancel) for nestjs-durable — bundled React SPA served by NestJS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Davide Carvalho",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"tsup": "8.3.5",
|
|
57
57
|
"typescript": "5.9.3",
|
|
58
58
|
"vite": "5.4.21",
|
|
59
|
-
"@dudousxd/nestjs-durable-core": "^0.
|
|
59
|
+
"@dudousxd/nestjs-durable-core": "^0.46.0"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "vite build && tsup && tsc -p tsconfig.client.json",
|