@dudousxd/nestjs-durable-dashboard 0.30.0 → 0.31.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.
@@ -555,6 +555,7 @@ DurableApiController = _ts_decorate2([
555
555
  ], DurableApiController);
556
556
 
557
557
  // src/server/durable-dashboard.module.ts
558
+ var import_reflect_metadata = require("reflect-metadata");
558
559
  var import_common4 = require("@nestjs/common");
559
560
  var import_core = require("@nestjs/core");
560
561
 
@@ -669,27 +670,42 @@ function _ts_decorate4(decorators, target, key, desc) {
669
670
  return c > 3 && r && Object.defineProperty(target, key, r), r;
670
671
  }
671
672
  __name(_ts_decorate4, "_ts_decorate");
673
+ var GUARDS_METADATA = "__guards__";
672
674
  function normalize(path) {
673
675
  return `/${path.replace(/^\/+|\/+$/g, "")}`;
674
676
  }
675
677
  __name(normalize, "normalize");
676
- var DurableApiModule = class {
678
+ function stampGuards(guards, ...controllers) {
679
+ for (const controller of controllers) {
680
+ Reflect.defineMetadata(GUARDS_METADATA, guards ?? [], controller);
681
+ }
682
+ }
683
+ __name(stampGuards, "stampGuards");
684
+ var DurableApiModule = class _DurableApiModule {
677
685
  static {
678
686
  __name(this, "DurableApiModule");
679
687
  }
688
+ static register(options) {
689
+ return {
690
+ module: _DurableApiModule,
691
+ imports: [
692
+ ...options.imports ?? []
693
+ ],
694
+ controllers: [
695
+ DurableApiController
696
+ ],
697
+ providers: [
698
+ DashboardService,
699
+ ...options.guards ?? []
700
+ ],
701
+ exports: [
702
+ DashboardService
703
+ ]
704
+ };
705
+ }
680
706
  };
681
707
  DurableApiModule = _ts_decorate4([
682
- (0, import_common4.Module)({
683
- controllers: [
684
- DurableApiController
685
- ],
686
- providers: [
687
- DashboardService
688
- ],
689
- exports: [
690
- DashboardService
691
- ]
692
- })
708
+ (0, import_common4.Module)({})
693
709
  ], DurableApiModule);
694
710
  var DurableDashboardModule = class _DurableDashboardModule {
695
711
  static {
@@ -698,10 +714,22 @@ var DurableDashboardModule = class _DurableDashboardModule {
698
714
  static forRoot(options = {}) {
699
715
  const basePath = normalize(options.basePath ?? "/durable");
700
716
  const apiBasePath = normalize(options.apiBasePath ?? `${basePath}/api`);
717
+ stampGuards(options.guards, DurableApiController, DurableUiController);
701
718
  return {
702
719
  module: _DurableDashboardModule,
703
720
  imports: [
704
- DurableApiModule,
721
+ ...options.imports ?? [],
722
+ // Guards + host imports must reach the API controller's HOST module — enhancers resolve
723
+ // from their controller's own module, never from a parent (see DurableApiModule.register).
724
+ // Spread-only-when-set: exactOptionalPropertyTypes rejects an explicit `undefined`.
725
+ DurableApiModule.register({
726
+ ...options.imports ? {
727
+ imports: options.imports
728
+ } : {},
729
+ ...options.guards ? {
730
+ guards: options.guards
731
+ } : {}
732
+ }),
705
733
  import_core.RouterModule.register([
706
734
  {
707
735
  path: basePath,
@@ -724,7 +752,9 @@ var DurableDashboardModule = class _DurableDashboardModule {
724
752
  {
725
753
  provide: DASHBOARD_API_PATH,
726
754
  useValue: apiBasePath
727
- }
755
+ },
756
+ // DurableUiController is hosted HERE, so its guards DI-instantiate from this module.
757
+ ...options.guards ?? []
728
758
  ],
729
759
  // Re-export the API module so its DashboardService reaches importers (e.g. flip's own controllers).
730
760
  exports: [
@@ -1 +1 @@
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 DurableTopology,\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunDetail,\n type RunGateway,\n type RunListItem,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type UpdateResult,\n WorkflowEngine,\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\n// Single source of truth: core owns `RunDetail` (`run-gateway.ts`) — re-exported here (not\n// re-declared) so this package's existing public API (`import type { RunDetail } from\n// '@dudousxd/nestjs-durable-dashboard'`) keeps working unchanged.\nexport type { RunDetail };\n\n/**\n * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/\n * continue/retryWithInput/stream/bulk/workerHealth) route through the injected {@link RUN_GATEWAY}\n * port, which the lib binds on both topologies (a store-backed impl on the control plane, a proxy\n * over the transport on a store-less tenant) — so those ops work regardless of whether `store`/\n * `engine` are present (`workerHealth` is scoped to the tenant's own groups over the proxy).\n * Operator-only ops (metrics/deliverWebhook/getEvent/update) still need direct `store`/`engine`\n * 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<RunListItem[]> {\n return this.gateway.listRuns(query);\n }\n\n /** This deployment's durable role (control plane vs tenant) + tenant name — for the dashboard header\n * badge. Routes through the gateway, which knows its own topology (store-backed = operator, proxy =\n * tenant). Works on both topologies (no store/engine needed). */\n topology(): DurableTopology {\n return this.gateway.topology();\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`. Routes through the\n * gateway so it works on BOTH topologies: the control plane returns every group; a tenant proxies\n * to the operator, which scopes the result to the tenant's own `@<tenant>` groups. */\n async workerHealth(): Promise<GroupHealth[]> {\n return this.gateway.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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n redispatch(runId: string) {\n return this.gateway.redispatchPending(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 /** This deployment's durable role (control plane vs tenant) + tenant name — for the header badge. */\n @Get('topology')\n topology() {\n return this.dashboard.topology();\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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n @Post('runs/:id/redispatch')\n async redispatch(@Param('id') id: string) {\n const result = await this.dashboard.redispatch(id);\n if (!result) throw new NotFoundException(`run ${id} not found`);\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":["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","topology","getRunDetail","runId","retryWithInput","input","retry","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","redispatch","redispatchPending","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","topology","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","redispatch","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 DurableTopology,\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunDetail,\n type RunGateway,\n type RunListItem,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type UpdateResult,\n WorkflowEngine,\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\n// Single source of truth: core owns `RunDetail` (`run-gateway.ts`) — re-exported here (not\n// re-declared) so this package's existing public API (`import type { RunDetail } from\n// '@dudousxd/nestjs-durable-dashboard'`) keeps working unchanged.\nexport type { RunDetail };\n\n/**\n * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/\n * continue/retryWithInput/stream/bulk/workerHealth) route through the injected {@link RUN_GATEWAY}\n * port, which the lib binds on both topologies (a store-backed impl on the control plane, a proxy\n * over the transport on a store-less tenant) — so those ops work regardless of whether `store`/\n * `engine` are present (`workerHealth` is scoped to the tenant's own groups over the proxy).\n * Operator-only ops (metrics/deliverWebhook/getEvent/update) still need direct `store`/`engine`\n * 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<RunListItem[]> {\n return this.gateway.listRuns(query);\n }\n\n /** This deployment's durable role (control plane vs tenant) + tenant name — for the dashboard header\n * badge. Routes through the gateway, which knows its own topology (store-backed = operator, proxy =\n * tenant). Works on both topologies (no store/engine needed). */\n topology(): DurableTopology {\n return this.gateway.topology();\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`. Routes through the\n * gateway so it works on BOTH topologies: the control plane returns every group; a tenant proxies\n * to the operator, which scopes the result to the tenant's own `@<tenant>` groups. */\n async workerHealth(): Promise<GroupHealth[]> {\n return this.gateway.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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n redispatch(runId: string) {\n return this.gateway.redispatchPending(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 /** This deployment's durable role (control plane vs tenant) + tenant name — for the header badge. */\n @Get('topology')\n topology() {\n return this.dashboard.topology();\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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n @Post('runs/:id/redispatch')\n async redispatch(@Param('id') id: string) {\n const result = await this.dashboard.redispatch(id);\n if (!result) throw new NotFoundException(`run ${id} not found`);\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 'reflect-metadata';\nimport { type CanActivate, type DynamicModule, Module, type Type } 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\n/**\n * `@nestjs/common`'s own `GUARDS_METADATA` key, INLINED rather than deep-imported from\n * '@nestjs/common/constants' — that subpath has no extension and a strict ESM resolver (which the\n * built dual ESM/CJS output of this package is loaded under) 404s on it. A drift spec imports the\n * real constant (via the resolvable `'@nestjs/common/constants.js'` subpath) and asserts this literal\n * stays byte-identical to it. Mirrors `@dudousxd/nestjs-agent-dashboard`'s `agent-dashboard.module.ts`.\n */\nconst GUARDS_METADATA = '__guards__';\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 * Guard classes fronting BOTH dashboard controllers (the SPA at `basePath` and its JSON API at\n * `apiBasePath`). Stamped onto each controller via `@nestjs/common`'s own `@UseGuards` metadata key\n * — REPLACE semantics, so a second `forRoot(...)` call overwrites (not appends to) whatever a prior\n * call stamped, same as re-applying `@UseGuards` by hand. Omit to leave the routes unguarded (the\n * host fronts them another way, e.g. a global guard or reverse-proxy auth).\n *\n * A guard's own DEPENDENCIES resolve from this module's `imports` (see {@link imports}) — the\n * dashboard module has no application context of its own to pull them from otherwise.\n *\n * Auth reality for the two mount points: `apiBasePath` is fetched by the SPA's own JS (an XHR/fetch\n * from the same origin), so a guard reading a bearer/`Authorization` header there works exactly like\n * any other API route. `basePath` (the UI shell, `DurableUiController.index()`) is a full-page\n * navigation — the BROWSER issues that GET directly (typing the URL, a bookmark, a link click), so\n * there is no custom header to read: only whatever ambient credential the browser sends by default\n * (a cookie, or nothing) reaches the guard there. A guard that only checks an `Authorization` header\n * will 403 the page shell while the API calls it makes right after would have passed — put session\n * auth behind a cookie (or front the UI mount with your reverse proxy / SSO instead) if you need the\n * shell itself gated.\n */\n guards?: Type<CanActivate>[];\n /**\n * Extra `imports` merged into the dashboard's dynamic module — the DI resolution path for a class\n * passed to {@link guards} (or any other provider the controllers need reachable). Typically the\n * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.\n */\n imports?: DynamicModule['imports'];\n}\n\n/** Leading slash, no trailing slash. */\nfunction normalize(path: string): string {\n return `/${path.replace(/^\\/+|\\/+$/g, '')}`;\n}\n\n/** Stamp (or clear) `@UseGuards`-equivalent metadata on the dashboard controllers — REPLACE, not append. */\nfunction stampGuards(guards: Type<CanActivate>[] | undefined, ...controllers: Type[]): void {\n for (const controller of controllers) {\n Reflect.defineMetadata(GUARDS_METADATA, guards ?? [], controller);\n }\n}\n\n/**\n * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:\n * guards are DI-instantiated by the CONTROLLER's host module, so this module — not the outer wrapper\n * — must carry the guard classes as providers plus the host's `imports` that resolve their\n * dependencies. A static module here made `guards: [SomeGuardWithDeps]` fail at boot with \"Nest can't\n * resolve dependencies ... in the DurableApiModule context\" even when the host passed the right\n * `imports` to `forRoot` (mirrors `@dudousxd/nestjs-agent-dashboard`'s `AgentApiModule`).\n */\n@Module({})\nexport class DurableApiModule {\n static register(options: {\n imports?: DynamicModule['imports'];\n guards?: Type<CanActivate>[];\n }): DynamicModule {\n return {\n module: DurableApiModule,\n imports: [...(options.imports ?? [])],\n controllers: [DurableApiController],\n providers: [DashboardService, ...(options.guards ?? [])],\n exports: [DashboardService],\n };\n }\n}\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 the first-class\n * `guards` option (plus `imports` for the guards' own dependencies) — see\n * {@link DurableDashboardOptions.guards}.\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 stampGuards(options.guards, DurableApiController, DurableUiController);\n return {\n module: DurableDashboardModule,\n imports: [\n ...(options.imports ?? []),\n // Guards + host imports must reach the API controller's HOST module — enhancers resolve\n // from their controller's own module, never from a parent (see DurableApiModule.register).\n // Spread-only-when-set: exactOptionalPropertyTypes rejects an explicit `undefined`.\n DurableApiModule.register({\n ...(options.imports ? { imports: options.imports } : {}),\n ...(options.guards ? { guards: options.guards } : {}),\n }),\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 // DurableUiController is hosted HERE, so its guards DI-instantiate from this module.\n ...(options.guards ?? []),\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,iCAeO;AACP,oBAA6C;AAC7C,kBAA2B;;;ACLpB,IAAMA,cAAcC,OAAOC,IAAI,4BAAA;;;;;;;;;;;;;;;;;;;;ADuB/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;;;;EAKAC,WAA4B;AAC1B,WAAO,KAAK7B,QAAQ6B,SAAQ;EAC9B;;;;;EAMA,MAAMT,eAAuC;AAC3C,WAAO,KAAKpB,QAAQoB,aAAY;EAClC;EAEAU,aAAaC,OAA0C;AACrD,WAAO,KAAK/B,QAAQ8B,aAAaC,KAAAA;EACnC;;EAGAC,eAAeD,OAAeE,OAAmD;AAC/E,WAAO,KAAKjC,QAAQgC,eAAeD,OAAOE,KAAAA;EAC5C;;EAGAC,MAAMH,OAA0C;AAC9C,WAAO,KAAK/B,QAAQkC,MAAMH,KAAAA;EAC5B;EAEAI,OAAOJ,OAAeK,MAA4D;AAChF,WAAO,KAAKpC,QAAQmC,OAAOJ,OAAOK,IAAAA;EACpC;;;;;;EAOA,MAAMC,KACJC,QACAC,QACAH,MAC+C;AAC/C,UAAMI,OAAO,MAAM,KAAKxC,QAAQc,SAAS;MAAE,GAAGyB;MAAQvB,OAAO;IAAI,CAAA;AACjE,QAAIyB,UAAU;AACd,eAAWC,KAAKF,MAAM;AACpB,UAAI;AACF,YAAIF,WAAW,QAAS,OAAM,KAAKtC,QAAQkC,MAAMQ,EAAEC,EAAE;YAChD,OAAM,KAAK3C,QAAQmC,OAAOO,EAAEC,IAAIP,IAAAA;AACrCK,mBAAW;MACb,QAAQ;MAER;IACF;AACA,WAAO;MAAEG,SAASJ,KAAKtB;MAAQuB;IAAQ;EACzC;;EAGAI,SAASd,OAA0C;AACjD,WAAO,KAAK/B,QAAQ6C,SAASd,KAAAA;EAC/B;;EAGAe,WAAWf,OAAe;AACxB,WAAO,KAAK/B,QAAQ+C,kBAAkBhB,KAAAA;EACxC;;;;;;EAOA,MAAMiB,eAAeC,OAAeC,MAA0C;AAC5E,WAAO,KAAK7C,aAAY,EAAGH,OAAOiD,OAAOF,OAAOC,IAAAA;EAClD;;EAGA,MAAME,SAASrB,OAAesB,KAA+B;AAC3D,WAAO,KAAKhD,aAAY,EAAGH,OAAOkD,SAASrB,OAAOsB,GAAAA;EACpD;;EAGA,MAAMC,OAAOvB,OAAewB,MAAcC,KAAqC;AAC7E,WAAO,KAAKnD,aAAY,EAAGH,OAAOoD,OAAOvB,OAAOwB,MAAMC,GAAAA;EACxD;;;;;;;EAQAC,UAAU1B,OAAkD;AAC1D,WAAO,IAAI2B,uBAAkC,CAACC,eAAAA;AAC5C,YAAMC,MAAM,KAAK5D,QAAQ6D,UAAU9B,OAAO,CAAC+B,UAAUH,WAAWI,KAAK;QAAEC,MAAMF;MAAM,CAAA,CAAA;AACnF,aAAO,MAAMF,IAAAA;IACf,CAAA;EACF;AACF;;;;;;;;;;;;;;;;AE5MA,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;;EAIAC,WAAW;AACT,WAAO,KAAKZ,UAAUY,SAAQ;EAChC;EAEA,MACMC,IAAiBC,IAAY;AACjC,UAAMC,SAAS,MAAM,KAAKf,UAAUgB,aAAaF,EAAAA;AACjD,QAAI,CAACC,OAAQ,OAAM,IAAIE,iCAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOC;EACT;;;EAKAG,OAAoBJ,IAAY;AAC9B,WAAO,KAAKd,UAAUmB,UAAUL,EAAAA;EAClC;EAGAM,MAAmBN,IAAY;AAC7B,WAAO,KAAKd,UAAUoB,MAAMN,EAAAA;EAC9B;;EAIAO,eAA4BP,IAAoBQ,MAA0B;AACxE,WAAO,KAAKtB,UAAUqB,eAAeP,IAAIQ,MAAMC,KAAAA;EACjD;;EAIAC,KACmBC,QACAvB,QACHE,KACKD,UACJE,MACMqB,YACrB;AACA,WAAO,KAAK1B,UAAUwB,KACpBC,WAAW,WAAW,WAAW,SACjC;MAAEvB;MAAQE;MAAKD;MAAUI,YAAYC,iBAAiBH,IAAAA;IAAM,GAC5D;MAAEqB,YAAYA,eAAe;IAAO,CAAA;EAExC;EAEA,MACMC,OAAoBb,IAAiCY,YAAqB;AAC9E,UAAME,SAAS,MAAM,KAAK5B,UAAU2B,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,KAAK5B,UAAU6B,SAASf,EAAAA;AAC7C,QAAI,CAACc,OAAQ,OAAM,IAAIX,iCAAkB,OAAOH,EAAAA,gCAAkC;AAClF,WAAOc;EACT;;EAGA,MACME,WAAwBhB,IAAY;AACxC,UAAMc,SAAS,MAAM,KAAK5B,UAAU8B,WAAWhB,EAAAA;AAC/C,QAAI,CAACc,OAAQ,OAAM,IAAIX,iCAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOc;EACT;;;;;;;EAQA,MACMG,QAAwBC,OAAuBV,MAAe;AAClE,UAAMM,SAAS,MAAM,KAAK5B,UAAUiC,eAAeD,OAAOV,IAAAA;AAC1D,QAAI,CAACM,OAAQ,OAAM,IAAIX,iCAAkB,gCAAgCe,KAAAA,EAAO;AAChF,WAAOJ;EACT;;EAIAM,MAAmBpB,IAA0BqB,KAAa;AACxD,WAAO,KAAKnC,UAAUoC,SAAStB,IAAIqB,GAAAA;EACrC;;EAIAE,OAAoBvB,IAA2BwB,MAAsBhB,MAAe;AAClF,WAAO,KAAKtB,UAAUqC,OAAOvB,IAAIwB,MAAMhB,IAAAA;EACzC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AEzIA,8BAAO;AACP,IAAAiB,iBAAwE;AACxE,kBAA6B;;;ACF7B,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ADlEA,IAAMG,kBAAkB;AA4CxB,SAASC,UAAUC,MAAY;AAC7B,SAAO,IAAIA,KAAKC,QAAQ,cAAc,EAAA,CAAA;AACxC;AAFSF;AAKT,SAASG,YAAYC,WAA4CC,aAAmB;AAClF,aAAWC,cAAcD,aAAa;AACpCE,YAAQC,eAAeT,iBAAiBK,UAAU,CAAA,GAAIE,UAAAA;EACxD;AACF;AAJSH;AAeF,IAAMM,mBAAN,MAAMA,kBAAAA;SAAAA;;;EACX,OAAOC,SAASC,SAGE;AAChB,WAAO;MACLC,QAAQH;MACRI,SAAS;WAAKF,QAAQE,WAAW,CAAA;;MACjCR,aAAa;QAACS;;MACdC,WAAW;QAACC;WAAsBL,QAAQP,UAAU,CAAA;;MACpDa,SAAS;QAACD;;IACZ;EACF;AACF;;;;AAUO,IAAME,yBAAN,MAAMA,wBAAAA;SAAAA;;;EACX,OAAOC,QAAQR,UAAmC,CAAC,GAAkB;AACnE,UAAMS,WAAWpB,UAAUW,QAAQS,YAAY,UAAA;AAC/C,UAAMC,cAAcrB,UAAUW,QAAQU,eAAe,GAAGD,QAAAA,MAAc;AACtEjB,gBAAYQ,QAAQP,QAAQU,sBAAsBQ,mBAAAA;AAClD,WAAO;MACLV,QAAQM;MACRL,SAAS;WACHF,QAAQE,WAAW,CAAA;;;;QAIvBJ,iBAAiBC,SAAS;UACxB,GAAIC,QAAQE,UAAU;YAAEA,SAASF,QAAQE;UAAQ,IAAI,CAAC;UACtD,GAAIF,QAAQP,SAAS;YAAEA,QAAQO,QAAQP;UAAO,IAAI,CAAC;QACrD,CAAA;QACAmB,yBAAab,SAAS;UACpB;YAAET,MAAMmB;YAAUR,QAAQM;UAAuB;UACjD;YAAEjB,MAAMoB;YAAaT,QAAQH;UAAiB;SAC/C;;MAEHJ,aAAa;QAACiB;;MACdP,WAAW;QACT;UAAES,SAASC;UAAqBC,UAAUN;QAAS;QACnD;UAAEI,SAASG;UAAoBD,UAAUL;QAAY;;WAEjDV,QAAQP,UAAU,CAAA;;;MAGxBa,SAAS;QAACR;;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","topology","getRunDetail","runId","retryWithInput","input","retry","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","redispatch","redispatchPending","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","topology","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","redispatch","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","GUARDS_METADATA","normalize","path","replace","stampGuards","guards","controllers","controller","Reflect","defineMetadata","DurableApiModule","register","options","module","imports","DurableApiController","providers","DashboardService","exports","DurableDashboardModule","forRoot","basePath","apiBasePath","DurableUiController","RouterModule","provide","DASHBOARD_BASE_PATH","useValue","DASHBOARD_API_PATH"]}
@@ -3,7 +3,7 @@ import { RunGateway, StateStore, WorkflowEngine, RunQuery, RunListItem, DurableT
3
3
  export { RunDetail } from '@dudousxd/nestjs-durable-core';
4
4
  import * as rxjs from 'rxjs';
5
5
  import { Observable } from 'rxjs';
6
- import { DynamicModule, StreamableFile } from '@nestjs/common';
6
+ import { DynamicModule, Type, CanActivate, StreamableFile } from '@nestjs/common';
7
7
 
8
8
  /**
9
9
  * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/
@@ -154,14 +154,54 @@ interface DurableDashboardOptions {
154
154
  * while the UI stays at `basePath`.
155
155
  */
156
156
  apiBasePath?: string;
157
+ /**
158
+ * Guard classes fronting BOTH dashboard controllers (the SPA at `basePath` and its JSON API at
159
+ * `apiBasePath`). Stamped onto each controller via `@nestjs/common`'s own `@UseGuards` metadata key
160
+ * — REPLACE semantics, so a second `forRoot(...)` call overwrites (not appends to) whatever a prior
161
+ * call stamped, same as re-applying `@UseGuards` by hand. Omit to leave the routes unguarded (the
162
+ * host fronts them another way, e.g. a global guard or reverse-proxy auth).
163
+ *
164
+ * A guard's own DEPENDENCIES resolve from this module's `imports` (see {@link imports}) — the
165
+ * dashboard module has no application context of its own to pull them from otherwise.
166
+ *
167
+ * Auth reality for the two mount points: `apiBasePath` is fetched by the SPA's own JS (an XHR/fetch
168
+ * from the same origin), so a guard reading a bearer/`Authorization` header there works exactly like
169
+ * any other API route. `basePath` (the UI shell, `DurableUiController.index()`) is a full-page
170
+ * navigation — the BROWSER issues that GET directly (typing the URL, a bookmark, a link click), so
171
+ * there is no custom header to read: only whatever ambient credential the browser sends by default
172
+ * (a cookie, or nothing) reaches the guard there. A guard that only checks an `Authorization` header
173
+ * will 403 the page shell while the API calls it makes right after would have passed — put session
174
+ * auth behind a cookie (or front the UI mount with your reverse proxy / SSO instead) if you need the
175
+ * shell itself gated.
176
+ */
177
+ guards?: Type<CanActivate>[];
178
+ /**
179
+ * Extra `imports` merged into the dashboard's dynamic module — the DI resolution path for a class
180
+ * passed to {@link guards} (or any other provider the controllers need reachable). Typically the
181
+ * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.
182
+ */
183
+ imports?: DynamicModule['imports'];
157
184
  }
158
- /** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */
185
+ /**
186
+ * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:
187
+ * guards are DI-instantiated by the CONTROLLER's host module, so this module — not the outer wrapper
188
+ * — must carry the guard classes as providers plus the host's `imports` that resolve their
189
+ * dependencies. A static module here made `guards: [SomeGuardWithDeps]` fail at boot with "Nest can't
190
+ * resolve dependencies ... in the DurableApiModule context" even when the host passed the right
191
+ * `imports` to `forRoot` (mirrors `@dudousxd/nestjs-agent-dashboard`'s `AgentApiModule`).
192
+ */
159
193
  declare class DurableApiModule {
194
+ static register(options: {
195
+ imports?: DynamicModule['imports'];
196
+ guards?: Type<CanActivate>[];
197
+ }): DynamicModule;
160
198
  }
161
199
  /**
162
200
  * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`
163
201
  * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside
164
- * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.
202
+ * `DurableModule` (global), so it resolves the engine and store. Front the routes with the first-class
203
+ * `guards` option (plus `imports` for the guards' own dependencies) — see
204
+ * {@link DurableDashboardOptions.guards}.
165
205
  */
166
206
  declare class DurableDashboardModule {
167
207
  static forRoot(options?: DurableDashboardOptions): DynamicModule;
@@ -3,7 +3,7 @@ import { RunGateway, StateStore, WorkflowEngine, RunQuery, RunListItem, DurableT
3
3
  export { RunDetail } from '@dudousxd/nestjs-durable-core';
4
4
  import * as rxjs from 'rxjs';
5
5
  import { Observable } from 'rxjs';
6
- import { DynamicModule, StreamableFile } from '@nestjs/common';
6
+ import { DynamicModule, Type, CanActivate, StreamableFile } from '@nestjs/common';
7
7
 
8
8
  /**
9
9
  * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/
@@ -154,14 +154,54 @@ interface DurableDashboardOptions {
154
154
  * while the UI stays at `basePath`.
155
155
  */
156
156
  apiBasePath?: string;
157
+ /**
158
+ * Guard classes fronting BOTH dashboard controllers (the SPA at `basePath` and its JSON API at
159
+ * `apiBasePath`). Stamped onto each controller via `@nestjs/common`'s own `@UseGuards` metadata key
160
+ * — REPLACE semantics, so a second `forRoot(...)` call overwrites (not appends to) whatever a prior
161
+ * call stamped, same as re-applying `@UseGuards` by hand. Omit to leave the routes unguarded (the
162
+ * host fronts them another way, e.g. a global guard or reverse-proxy auth).
163
+ *
164
+ * A guard's own DEPENDENCIES resolve from this module's `imports` (see {@link imports}) — the
165
+ * dashboard module has no application context of its own to pull them from otherwise.
166
+ *
167
+ * Auth reality for the two mount points: `apiBasePath` is fetched by the SPA's own JS (an XHR/fetch
168
+ * from the same origin), so a guard reading a bearer/`Authorization` header there works exactly like
169
+ * any other API route. `basePath` (the UI shell, `DurableUiController.index()`) is a full-page
170
+ * navigation — the BROWSER issues that GET directly (typing the URL, a bookmark, a link click), so
171
+ * there is no custom header to read: only whatever ambient credential the browser sends by default
172
+ * (a cookie, or nothing) reaches the guard there. A guard that only checks an `Authorization` header
173
+ * will 403 the page shell while the API calls it makes right after would have passed — put session
174
+ * auth behind a cookie (or front the UI mount with your reverse proxy / SSO instead) if you need the
175
+ * shell itself gated.
176
+ */
177
+ guards?: Type<CanActivate>[];
178
+ /**
179
+ * Extra `imports` merged into the dashboard's dynamic module — the DI resolution path for a class
180
+ * passed to {@link guards} (or any other provider the controllers need reachable). Typically the
181
+ * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.
182
+ */
183
+ imports?: DynamicModule['imports'];
157
184
  }
158
- /** Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. */
185
+ /**
186
+ * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:
187
+ * guards are DI-instantiated by the CONTROLLER's host module, so this module — not the outer wrapper
188
+ * — must carry the guard classes as providers plus the host's `imports` that resolve their
189
+ * dependencies. A static module here made `guards: [SomeGuardWithDeps]` fail at boot with "Nest can't
190
+ * resolve dependencies ... in the DurableApiModule context" even when the host passed the right
191
+ * `imports` to `forRoot` (mirrors `@dudousxd/nestjs-agent-dashboard`'s `AgentApiModule`).
192
+ */
159
193
  declare class DurableApiModule {
194
+ static register(options: {
195
+ imports?: DynamicModule['imports'];
196
+ guards?: Type<CanActivate>[];
197
+ }): DynamicModule;
160
198
  }
161
199
  /**
162
200
  * Mounts the control plane: the bundled React SPA at `basePath` and its JSON API at `apiBasePath`
163
201
  * (default `<basePath>/api`). Import via `DurableDashboardModule.forRoot(...)` alongside
164
- * `DurableModule` (global), so it resolves the engine and store. Front the routes with a guard.
202
+ * `DurableModule` (global), so it resolves the engine and store. Front the routes with the first-class
203
+ * `guards` option (plus `imports` for the guards' own dependencies) — see
204
+ * {@link DurableDashboardOptions.guards}.
165
205
  */
166
206
  declare class DurableDashboardModule {
167
207
  static forRoot(options?: DurableDashboardOptions): DynamicModule;
@@ -524,6 +524,7 @@ DurableApiController = _ts_decorate2([
524
524
  ], DurableApiController);
525
525
 
526
526
  // src/server/durable-dashboard.module.ts
527
+ import "reflect-metadata";
527
528
  import { Module } from "@nestjs/common";
528
529
  import { RouterModule } from "@nestjs/core";
529
530
 
@@ -638,27 +639,42 @@ function _ts_decorate4(decorators, target, key, desc) {
638
639
  return c > 3 && r && Object.defineProperty(target, key, r), r;
639
640
  }
640
641
  __name(_ts_decorate4, "_ts_decorate");
642
+ var GUARDS_METADATA = "__guards__";
641
643
  function normalize(path) {
642
644
  return `/${path.replace(/^\/+|\/+$/g, "")}`;
643
645
  }
644
646
  __name(normalize, "normalize");
645
- var DurableApiModule = class {
647
+ function stampGuards(guards, ...controllers) {
648
+ for (const controller of controllers) {
649
+ Reflect.defineMetadata(GUARDS_METADATA, guards ?? [], controller);
650
+ }
651
+ }
652
+ __name(stampGuards, "stampGuards");
653
+ var DurableApiModule = class _DurableApiModule {
646
654
  static {
647
655
  __name(this, "DurableApiModule");
648
656
  }
657
+ static register(options) {
658
+ return {
659
+ module: _DurableApiModule,
660
+ imports: [
661
+ ...options.imports ?? []
662
+ ],
663
+ controllers: [
664
+ DurableApiController
665
+ ],
666
+ providers: [
667
+ DashboardService,
668
+ ...options.guards ?? []
669
+ ],
670
+ exports: [
671
+ DashboardService
672
+ ]
673
+ };
674
+ }
649
675
  };
650
676
  DurableApiModule = _ts_decorate4([
651
- Module({
652
- controllers: [
653
- DurableApiController
654
- ],
655
- providers: [
656
- DashboardService
657
- ],
658
- exports: [
659
- DashboardService
660
- ]
661
- })
677
+ Module({})
662
678
  ], DurableApiModule);
663
679
  var DurableDashboardModule = class _DurableDashboardModule {
664
680
  static {
@@ -667,10 +683,22 @@ var DurableDashboardModule = class _DurableDashboardModule {
667
683
  static forRoot(options = {}) {
668
684
  const basePath = normalize(options.basePath ?? "/durable");
669
685
  const apiBasePath = normalize(options.apiBasePath ?? `${basePath}/api`);
686
+ stampGuards(options.guards, DurableApiController, DurableUiController);
670
687
  return {
671
688
  module: _DurableDashboardModule,
672
689
  imports: [
673
- DurableApiModule,
690
+ ...options.imports ?? [],
691
+ // Guards + host imports must reach the API controller's HOST module — enhancers resolve
692
+ // from their controller's own module, never from a parent (see DurableApiModule.register).
693
+ // Spread-only-when-set: exactOptionalPropertyTypes rejects an explicit `undefined`.
694
+ DurableApiModule.register({
695
+ ...options.imports ? {
696
+ imports: options.imports
697
+ } : {},
698
+ ...options.guards ? {
699
+ guards: options.guards
700
+ } : {}
701
+ }),
674
702
  RouterModule.register([
675
703
  {
676
704
  path: basePath,
@@ -693,7 +721,9 @@ var DurableDashboardModule = class _DurableDashboardModule {
693
721
  {
694
722
  provide: DASHBOARD_API_PATH,
695
723
  useValue: apiBasePath
696
- }
724
+ },
725
+ // DurableUiController is hosted HERE, so its guards DI-instantiate from this module.
726
+ ...options.guards ?? []
697
727
  ],
698
728
  // Re-export the API module so its DashboardService reaches importers (e.g. flip's own controllers).
699
729
  exports: [
@@ -1 +1 @@
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 DurableTopology,\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunDetail,\n type RunGateway,\n type RunListItem,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type UpdateResult,\n WorkflowEngine,\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\n// Single source of truth: core owns `RunDetail` (`run-gateway.ts`) — re-exported here (not\n// re-declared) so this package's existing public API (`import type { RunDetail } from\n// '@dudousxd/nestjs-durable-dashboard'`) keeps working unchanged.\nexport type { RunDetail };\n\n/**\n * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/\n * continue/retryWithInput/stream/bulk/workerHealth) route through the injected {@link RUN_GATEWAY}\n * port, which the lib binds on both topologies (a store-backed impl on the control plane, a proxy\n * over the transport on a store-less tenant) — so those ops work regardless of whether `store`/\n * `engine` are present (`workerHealth` is scoped to the tenant's own groups over the proxy).\n * Operator-only ops (metrics/deliverWebhook/getEvent/update) still need direct `store`/`engine`\n * 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<RunListItem[]> {\n return this.gateway.listRuns(query);\n }\n\n /** This deployment's durable role (control plane vs tenant) + tenant name — for the dashboard header\n * badge. Routes through the gateway, which knows its own topology (store-backed = operator, proxy =\n * tenant). Works on both topologies (no store/engine needed). */\n topology(): DurableTopology {\n return this.gateway.topology();\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`. Routes through the\n * gateway so it works on BOTH topologies: the control plane returns every group; a tenant proxies\n * to the operator, which scopes the result to the tenant's own `@<tenant>` groups. */\n async workerHealth(): Promise<GroupHealth[]> {\n return this.gateway.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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n redispatch(runId: string) {\n return this.gateway.redispatchPending(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 /** This deployment's durable role (control plane vs tenant) + tenant name — for the header badge. */\n @Get('topology')\n topology() {\n return this.dashboard.topology();\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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n @Post('runs/:id/redispatch')\n async redispatch(@Param('id') id: string) {\n const result = await this.dashboard.redispatch(id);\n if (!result) throw new NotFoundException(`run ${id} not found`);\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","topology","getRunDetail","runId","retryWithInput","input","retry","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","redispatch","redispatchPending","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","topology","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","redispatch","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 DurableTopology,\n type EngineEvent,\n type GroupHealth,\n type MetricsCollector,\n type RunDetail,\n type RunGateway,\n type RunListItem,\n type RunQuery,\n type RunResult,\n STATE_STORE_CANONICAL,\n type StateStore,\n type UpdateResult,\n WorkflowEngine,\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\n// Single source of truth: core owns `RunDetail` (`run-gateway.ts`) — re-exported here (not\n// re-declared) so this package's existing public API (`import type { RunDetail } from\n// '@dudousxd/nestjs-durable-dashboard'`) keeps working unchanged.\nexport type { RunDetail };\n\n/**\n * Read-model and actions backing the control-plane UI. Run-facing ops (list/detail/retry/cancel/\n * continue/retryWithInput/stream/bulk/workerHealth) route through the injected {@link RUN_GATEWAY}\n * port, which the lib binds on both topologies (a store-backed impl on the control plane, a proxy\n * over the transport on a store-less tenant) — so those ops work regardless of whether `store`/\n * `engine` are present (`workerHealth` is scoped to the tenant's own groups over the proxy).\n * Operator-only ops (metrics/deliverWebhook/getEvent/update) still need direct `store`/`engine`\n * 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<RunListItem[]> {\n return this.gateway.listRuns(query);\n }\n\n /** This deployment's durable role (control plane vs tenant) + tenant name — for the dashboard header\n * badge. Routes through the gateway, which knows its own topology (store-backed = operator, proxy =\n * tenant). Works on both topologies (no store/engine needed). */\n topology(): DurableTopology {\n return this.gateway.topology();\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`. Routes through the\n * gateway so it works on BOTH topologies: the control plane returns every group; a tenant proxies\n * to the operator, which scopes the result to the tenant's own `@<tenant>` groups. */\n async workerHealth(): Promise<GroupHealth[]> {\n return this.gateway.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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n redispatch(runId: string) {\n return this.gateway.redispatchPending(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 /** This deployment's durable role (control plane vs tenant) + tenant name — for the header badge. */\n @Get('topology')\n topology() {\n return this.dashboard.topology();\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 /** Re-dispatch a run's stuck `pending` remote steps — recovery for a lost step dispatch. */\n @Post('runs/:id/redispatch')\n async redispatch(@Param('id') id: string) {\n const result = await this.dashboard.redispatch(id);\n if (!result) throw new NotFoundException(`run ${id} not found`);\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 'reflect-metadata';\nimport { type CanActivate, type DynamicModule, Module, type Type } 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\n/**\n * `@nestjs/common`'s own `GUARDS_METADATA` key, INLINED rather than deep-imported from\n * '@nestjs/common/constants' — that subpath has no extension and a strict ESM resolver (which the\n * built dual ESM/CJS output of this package is loaded under) 404s on it. A drift spec imports the\n * real constant (via the resolvable `'@nestjs/common/constants.js'` subpath) and asserts this literal\n * stays byte-identical to it. Mirrors `@dudousxd/nestjs-agent-dashboard`'s `agent-dashboard.module.ts`.\n */\nconst GUARDS_METADATA = '__guards__';\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 * Guard classes fronting BOTH dashboard controllers (the SPA at `basePath` and its JSON API at\n * `apiBasePath`). Stamped onto each controller via `@nestjs/common`'s own `@UseGuards` metadata key\n * — REPLACE semantics, so a second `forRoot(...)` call overwrites (not appends to) whatever a prior\n * call stamped, same as re-applying `@UseGuards` by hand. Omit to leave the routes unguarded (the\n * host fronts them another way, e.g. a global guard or reverse-proxy auth).\n *\n * A guard's own DEPENDENCIES resolve from this module's `imports` (see {@link imports}) — the\n * dashboard module has no application context of its own to pull them from otherwise.\n *\n * Auth reality for the two mount points: `apiBasePath` is fetched by the SPA's own JS (an XHR/fetch\n * from the same origin), so a guard reading a bearer/`Authorization` header there works exactly like\n * any other API route. `basePath` (the UI shell, `DurableUiController.index()`) is a full-page\n * navigation — the BROWSER issues that GET directly (typing the URL, a bookmark, a link click), so\n * there is no custom header to read: only whatever ambient credential the browser sends by default\n * (a cookie, or nothing) reaches the guard there. A guard that only checks an `Authorization` header\n * will 403 the page shell while the API calls it makes right after would have passed — put session\n * auth behind a cookie (or front the UI mount with your reverse proxy / SSO instead) if you need the\n * shell itself gated.\n */\n guards?: Type<CanActivate>[];\n /**\n * Extra `imports` merged into the dashboard's dynamic module — the DI resolution path for a class\n * passed to {@link guards} (or any other provider the controllers need reachable). Typically the\n * host's own auth module, e.g. `imports: [AuthModule]` alongside `guards: [JwtAuthGuard]`.\n */\n imports?: DynamicModule['imports'];\n}\n\n/** Leading slash, no trailing slash. */\nfunction normalize(path: string): string {\n return `/${path.replace(/^\\/+|\\/+$/g, '')}`;\n}\n\n/** Stamp (or clear) `@UseGuards`-equivalent metadata on the dashboard controllers — REPLACE, not append. */\nfunction stampGuards(guards: Type<CanActivate>[] | undefined, ...controllers: Type[]): void {\n for (const controller of controllers) {\n Reflect.defineMetadata(GUARDS_METADATA, guards ?? [], controller);\n }\n}\n\n/**\n * Holds the JSON API controller + its read service, mounted on its own path by `forRoot`. Dynamic:\n * guards are DI-instantiated by the CONTROLLER's host module, so this module — not the outer wrapper\n * — must carry the guard classes as providers plus the host's `imports` that resolve their\n * dependencies. A static module here made `guards: [SomeGuardWithDeps]` fail at boot with \"Nest can't\n * resolve dependencies ... in the DurableApiModule context\" even when the host passed the right\n * `imports` to `forRoot` (mirrors `@dudousxd/nestjs-agent-dashboard`'s `AgentApiModule`).\n */\n@Module({})\nexport class DurableApiModule {\n static register(options: {\n imports?: DynamicModule['imports'];\n guards?: Type<CanActivate>[];\n }): DynamicModule {\n return {\n module: DurableApiModule,\n imports: [...(options.imports ?? [])],\n controllers: [DurableApiController],\n providers: [DashboardService, ...(options.guards ?? [])],\n exports: [DashboardService],\n };\n }\n}\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 the first-class\n * `guards` option (plus `imports` for the guards' own dependencies) — see\n * {@link DurableDashboardOptions.guards}.\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 stampGuards(options.guards, DurableApiController, DurableUiController);\n return {\n module: DurableDashboardModule,\n imports: [\n ...(options.imports ?? []),\n // Guards + host imports must reach the API controller's HOST module — enhancers resolve\n // from their controller's own module, never from a parent (see DurableApiModule.register).\n // Spread-only-when-set: exactOptionalPropertyTypes rejects an explicit `undefined`.\n DurableApiModule.register({\n ...(options.imports ? { imports: options.imports } : {}),\n ...(options.guards ? { guards: options.guards } : {}),\n }),\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 // DurableUiController is hosted HERE, so its guards DI-instantiate from this module.\n ...(options.guards ?? []),\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,SAUEA,uBAGAC,gBACAC,sBACK;AACP,SAASC,QAAQC,YAAYC,gBAAgB;AAC7C,SAASC,kBAAkB;;;ACLpB,IAAMC,cAAcC,OAAOC,IAAI,4BAAA;;;;;;;;;;;;;;;;;;;;ADuB/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,SAASC,eAAe,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;;;;EAKAC,WAA4B;AAC1B,WAAO,KAAK7B,QAAQ6B,SAAQ;EAC9B;;;;;EAMA,MAAMT,eAAuC;AAC3C,WAAO,KAAKpB,QAAQoB,aAAY;EAClC;EAEAU,aAAaC,OAA0C;AACrD,WAAO,KAAK/B,QAAQ8B,aAAaC,KAAAA;EACnC;;EAGAC,eAAeD,OAAeE,OAAmD;AAC/E,WAAO,KAAKjC,QAAQgC,eAAeD,OAAOE,KAAAA;EAC5C;;EAGAC,MAAMH,OAA0C;AAC9C,WAAO,KAAK/B,QAAQkC,MAAMH,KAAAA;EAC5B;EAEAI,OAAOJ,OAAeK,MAA4D;AAChF,WAAO,KAAKpC,QAAQmC,OAAOJ,OAAOK,IAAAA;EACpC;;;;;;EAOA,MAAMC,KACJC,QACAC,QACAH,MAC+C;AAC/C,UAAMI,OAAO,MAAM,KAAKxC,QAAQc,SAAS;MAAE,GAAGyB;MAAQvB,OAAO;IAAI,CAAA;AACjE,QAAIyB,UAAU;AACd,eAAWC,KAAKF,MAAM;AACpB,UAAI;AACF,YAAIF,WAAW,QAAS,OAAM,KAAKtC,QAAQkC,MAAMQ,EAAEC,EAAE;YAChD,OAAM,KAAK3C,QAAQmC,OAAOO,EAAEC,IAAIP,IAAAA;AACrCK,mBAAW;MACb,QAAQ;MAER;IACF;AACA,WAAO;MAAEG,SAASJ,KAAKtB;MAAQuB;IAAQ;EACzC;;EAGAI,SAASd,OAA0C;AACjD,WAAO,KAAK/B,QAAQ6C,SAASd,KAAAA;EAC/B;;EAGAe,WAAWf,OAAe;AACxB,WAAO,KAAK/B,QAAQ+C,kBAAkBhB,KAAAA;EACxC;;;;;;EAOA,MAAMiB,eAAeC,OAAeC,MAA0C;AAC5E,WAAO,KAAK7C,aAAY,EAAGH,OAAOiD,OAAOF,OAAOC,IAAAA;EAClD;;EAGA,MAAME,SAASrB,OAAesB,KAA+B;AAC3D,WAAO,KAAKhD,aAAY,EAAGH,OAAOkD,SAASrB,OAAOsB,GAAAA;EACpD;;EAGA,MAAMC,OAAOvB,OAAewB,MAAcC,KAAqC;AAC7E,WAAO,KAAKnD,aAAY,EAAGH,OAAOoD,OAAOvB,OAAOwB,MAAMC,GAAAA;EACxD;;;;;;;EAQAC,UAAU1B,OAAkD;AAC1D,WAAO,IAAI2B,WAAkC,CAACC,eAAAA;AAC5C,YAAMC,MAAM,KAAK5D,QAAQ6D,UAAU9B,OAAO,CAAC+B,UAAUH,WAAWI,KAAK;QAAEC,MAAMF;MAAM,CAAA,CAAA;AACnF,aAAO,MAAMF,IAAAA;IACf,CAAA;EACF;AACF;;;;;;;;;;;;;;;;AE5MA,SACEK,MACAC,YACAC,KACAC,QACAC,mBACAC,OACAC,MACAC,OACAC,WACK;;;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;;EAIAC,WAAW;AACT,WAAO,KAAKZ,UAAUY,SAAQ;EAChC;EAEA,MACMC,IAAiBC,IAAY;AACjC,UAAMC,SAAS,MAAM,KAAKf,UAAUgB,aAAaF,EAAAA;AACjD,QAAI,CAACC,OAAQ,OAAM,IAAIE,kBAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOC;EACT;;;EAKAG,OAAoBJ,IAAY;AAC9B,WAAO,KAAKd,UAAUmB,UAAUL,EAAAA;EAClC;EAGAM,MAAmBN,IAAY;AAC7B,WAAO,KAAKd,UAAUoB,MAAMN,EAAAA;EAC9B;;EAIAO,eAA4BP,IAAoBQ,MAA0B;AACxE,WAAO,KAAKtB,UAAUqB,eAAeP,IAAIQ,MAAMC,KAAAA;EACjD;;EAIAC,KACmBC,QACAvB,QACHE,KACKD,UACJE,MACMqB,YACrB;AACA,WAAO,KAAK1B,UAAUwB,KACpBC,WAAW,WAAW,WAAW,SACjC;MAAEvB;MAAQE;MAAKD;MAAUI,YAAYC,iBAAiBH,IAAAA;IAAM,GAC5D;MAAEqB,YAAYA,eAAe;IAAO,CAAA;EAExC;EAEA,MACMC,OAAoBb,IAAiCY,YAAqB;AAC9E,UAAME,SAAS,MAAM,KAAK5B,UAAU2B,OAAOb,IAAI;MAAEY,YAAYA,eAAe;IAAO,CAAA;AACnF,QAAI,CAACE,OAAQ,OAAM,IAAIX,kBAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOc;EACT;EAEA,MACMC,SAAsBf,IAAY;AACtC,UAAMc,SAAS,MAAM,KAAK5B,UAAU6B,SAASf,EAAAA;AAC7C,QAAI,CAACc,OAAQ,OAAM,IAAIX,kBAAkB,OAAOH,EAAAA,gCAAkC;AAClF,WAAOc;EACT;;EAGA,MACME,WAAwBhB,IAAY;AACxC,UAAMc,SAAS,MAAM,KAAK5B,UAAU8B,WAAWhB,EAAAA;AAC/C,QAAI,CAACc,OAAQ,OAAM,IAAIX,kBAAkB,OAAOH,EAAAA,YAAc;AAC9D,WAAOc;EACT;;;;;;;EAQA,MACMG,QAAwBC,OAAuBV,MAAe;AAClE,UAAMM,SAAS,MAAM,KAAK5B,UAAUiC,eAAeD,OAAOV,IAAAA;AAC1D,QAAI,CAACM,OAAQ,OAAM,IAAIX,kBAAkB,gCAAgCe,KAAAA,EAAO;AAChF,WAAOJ;EACT;;EAIAM,MAAmBpB,IAA0BqB,KAAa;AACxD,WAAO,KAAKnC,UAAUoC,SAAStB,IAAIqB,GAAAA;EACrC;;EAIAE,OAAoBvB,IAA2BwB,MAAsBhB,MAAe;AAClF,WAAO,KAAKtB,UAAUqC,OAAOvB,IAAIwB,MAAMhB,IAAAA;EACzC;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AEzIA,OAAO;AACP,SAA+CiB,cAAyB;AACxE,SAASC,oBAAoB;;;ACF7B,SAASC,YAAYC,oBAAoB;AACzC,SAASC,UAAUC,SAASC,MAAMC,SAASC,WAAW;AACtD,SAASC,qBAAqB;AAC9B,SACEC,cAAAA,aACAC,OAAAA,MACAC,UAAAA,SACAC,UAAAA,SACAC,qBAAAA,oBACAC,SAAAA,QACAC,sBACK;;;;;;;;;;;;;;;;;;AAGA,IAAMC,sBAAsBC,OAAO,qBAAA;AAEnC,IAAMC,qBAAqBD,OAAO,oBAAA;AAGzC,IAAME,aAAa;AAGnB,SAASC,SAAAA;AACP,SAAOC,cAAc,IAAIC,IAAI,UAAU,YAAYC,GAAG,CAAA;AACxD;AAFSH;AAIT,IAAMI,gBAAwC;EAC5C,OAAO;EACP,QAAQ;EACR,QAAQ;EACR,QAAQ;EACR,SAAS;EACT,UAAU;EACV,QAAQ;AACV;AAQO,IAAMC,sBAAN,MAAMA;SAAAA;;;;;EACMC,MAAMN,OAAAA;EAEvB,YACgDO,UACDC,aAC7C;SAF8CD,WAAAA;SACDC,cAAAA;EAC5C;;;EAOHC,QAAgB;AACd,UAAMC,YAAYC,KAAK,KAAKL,KAAK,YAAA;AACjC,QAAI,CAACM,WAAWF,SAAAA,GAAY;AAC1B,YAAM,IAAIG,mBAAkB,gDAAA;IAC9B;AAGA,UAAMC,OAAOC,aAAaL,WAAW,MAAA,EAAQM,WAC3C,KAAKjB,UAAAA,KACL,KAAK,KAAKQ,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,OAAOC,SAASF,IAAAA;AACtB,QAAIC,SAASD,KAAM,OAAM,IAAIR,mBAAAA;AAC7B,UAAMW,OAAOC,QAAQ,KAAKnB,KAAK,QAAA;AAC/B,UAAMoB,YAAYD,QAAQD,MAAMF,IAAAA;AAChC,QAAI,CAACI,UAAUC,WAAWH,OAAOI,GAAAA,KAAQ,CAAChB,WAAWc,SAAAA,GAAY;AAC/D,YAAM,IAAIb,mBAAAA;IACZ;AACA,UAAMgB,OAAOzB,cAAc0B,QAAQR,IAAAA,CAAAA,KAAU;AAC7C,WAAO,IAAIS,eAAehB,aAAaW,SAAAA,GAAY;MAAEG;IAAK,CAAA;EAC5D;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ADlEA,IAAMG,kBAAkB;AA4CxB,SAASC,UAAUC,MAAY;AAC7B,SAAO,IAAIA,KAAKC,QAAQ,cAAc,EAAA,CAAA;AACxC;AAFSF;AAKT,SAASG,YAAYC,WAA4CC,aAAmB;AAClF,aAAWC,cAAcD,aAAa;AACpCE,YAAQC,eAAeT,iBAAiBK,UAAU,CAAA,GAAIE,UAAAA;EACxD;AACF;AAJSH;AAeF,IAAMM,mBAAN,MAAMA,kBAAAA;SAAAA;;;EACX,OAAOC,SAASC,SAGE;AAChB,WAAO;MACLC,QAAQH;MACRI,SAAS;WAAKF,QAAQE,WAAW,CAAA;;MACjCR,aAAa;QAACS;;MACdC,WAAW;QAACC;WAAsBL,QAAQP,UAAU,CAAA;;MACpDa,SAAS;QAACD;;IACZ;EACF;AACF;;;;AAUO,IAAME,yBAAN,MAAMA,wBAAAA;SAAAA;;;EACX,OAAOC,QAAQR,UAAmC,CAAC,GAAkB;AACnE,UAAMS,WAAWpB,UAAUW,QAAQS,YAAY,UAAA;AAC/C,UAAMC,cAAcrB,UAAUW,QAAQU,eAAe,GAAGD,QAAAA,MAAc;AACtEjB,gBAAYQ,QAAQP,QAAQU,sBAAsBQ,mBAAAA;AAClD,WAAO;MACLV,QAAQM;MACRL,SAAS;WACHF,QAAQE,WAAW,CAAA;;;;QAIvBJ,iBAAiBC,SAAS;UACxB,GAAIC,QAAQE,UAAU;YAAEA,SAASF,QAAQE;UAAQ,IAAI,CAAC;UACtD,GAAIF,QAAQP,SAAS;YAAEA,QAAQO,QAAQP;UAAO,IAAI,CAAC;QACrD,CAAA;QACAmB,aAAab,SAAS;UACpB;YAAET,MAAMmB;YAAUR,QAAQM;UAAuB;UACjD;YAAEjB,MAAMoB;YAAaT,QAAQH;UAAiB;SAC/C;;MAEHJ,aAAa;QAACiB;;MACdP,WAAW;QACT;UAAES,SAASC;UAAqBC,UAAUN;QAAS;QACnD;UAAEI,SAASG;UAAoBD,UAAUL;QAAY;;WAEjDV,QAAQP,UAAU,CAAA;;;MAGxBa,SAAS;QAACR;;IACZ;EACF;AACF;;;;","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","topology","getRunDetail","runId","retryWithInput","input","retry","cancel","opts","bulk","action","filter","runs","applied","r","id","matched","continue","redispatch","redispatchPending","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","topology","run","id","detail","getRunDetail","NotFoundException","stream","streamRun","retry","retryWithInput","body","input","bulk","action","compensate","cancel","result","continue","redispatch","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","GUARDS_METADATA","normalize","path","replace","stampGuards","guards","controllers","controller","Reflect","defineMetadata","DurableApiModule","register","options","module","imports","DurableApiController","providers","DashboardService","exports","DurableDashboardModule","forRoot","basePath","apiBasePath","DurableUiController","RouterModule","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.30.0",
3
+ "version": "0.31.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.50.0"
59
+ "@dudousxd/nestjs-durable-core": "^0.54.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "vite build && tsup && tsc -p tsconfig.client.json",