adispatchd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/api/errors.d.ts +27 -0
- package/dist/api/errors.js +80 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +11 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/logs.d.ts +30 -0
- package/dist/api/logs.js +88 -0
- package/dist/api/logs.js.map +1 -0
- package/dist/api/serialize.d.ts +65 -0
- package/dist/api/serialize.js +135 -0
- package/dist/api/serialize.js.map +1 -0
- package/dist/api/server.d.ts +89 -0
- package/dist/api/server.js +751 -0
- package/dist/api/server.js.map +1 -0
- package/dist/api/sse.d.ts +90 -0
- package/dist/api/sse.js +225 -0
- package/dist/api/sse.js.map +1 -0
- package/dist/app.d.ts +173 -0
- package/dist/app.js +488 -0
- package/dist/app.js.map +1 -0
- package/dist/cli/config-validate.d.ts +6 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/config-validate.js.map +1 -0
- package/dist/cli/control.d.ts +31 -0
- package/dist/cli/control.js +186 -0
- package/dist/cli/control.js.map +1 -0
- package/dist/cli/http-client.d.ts +57 -0
- package/dist/cli/http-client.js +174 -0
- package/dist/cli/http-client.js.map +1 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init-templates.d.ts +20 -0
- package/dist/cli/init-templates.js +275 -0
- package/dist/cli/init-templates.js.map +1 -0
- package/dist/cli/init.d.ts +16 -0
- package/dist/cli/init.js +107 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/program.d.ts +102 -0
- package/dist/cli/program.js +133 -0
- package/dist/cli/program.js.map +1 -0
- package/dist/cli/service.d.ts +27 -0
- package/dist/cli/service.js +252 -0
- package/dist/cli/service.js.map +1 -0
- package/dist/cli/start.d.ts +22 -0
- package/dist/cli/start.js +133 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/cli/workflow-dry-run.d.ts +13 -0
- package/dist/cli/workflow-dry-run.js +144 -0
- package/dist/cli/workflow-dry-run.js.map +1 -0
- package/dist/config/field-catalog.d.ts +33 -0
- package/dist/config/field-catalog.js +172 -0
- package/dist/config/field-catalog.js.map +1 -0
- package/dist/config/filter-schema.d.ts +15 -0
- package/dist/config/filter-schema.js +110 -0
- package/dist/config/filter-schema.js.map +1 -0
- package/dist/config/index.d.ts +15 -0
- package/dist/config/index.js +16 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/issues.d.ts +27 -0
- package/dist/config/issues.js +37 -0
- package/dist/config/issues.js.map +1 -0
- package/dist/config/loader.d.ts +32 -0
- package/dist/config/loader.js +250 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/manager.d.ts +153 -0
- package/dist/config/manager.js +276 -0
- package/dist/config/manager.js.map +1 -0
- package/dist/config/model.d.ts +334 -0
- package/dist/config/model.js +2 -0
- package/dist/config/model.js.map +1 -0
- package/dist/config/normalize.d.ts +20 -0
- package/dist/config/normalize.js +200 -0
- package/dist/config/normalize.js.map +1 -0
- package/dist/config/parameters.d.ts +37 -0
- package/dist/config/parameters.js +71 -0
- package/dist/config/parameters.js.map +1 -0
- package/dist/config/public.d.ts +6 -0
- package/dist/config/public.js +18 -0
- package/dist/config/public.js.map +1 -0
- package/dist/config/schemas.d.ts +538 -0
- package/dist/config/schemas.js +321 -0
- package/dist/config/schemas.js.map +1 -0
- package/dist/config/snapshot.d.ts +38 -0
- package/dist/config/snapshot.js +57 -0
- package/dist/config/snapshot.js.map +1 -0
- package/dist/config/validate.d.ts +33 -0
- package/dist/config/validate.js +346 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/core/clock.d.ts +59 -0
- package/dist/core/clock.js +133 -0
- package/dist/core/clock.js.map +1 -0
- package/dist/core/duration.d.ts +19 -0
- package/dist/core/duration.js +93 -0
- package/dist/core/duration.js.map +1 -0
- package/dist/core/errors.d.ts +62 -0
- package/dist/core/errors.js +72 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/ids.d.ts +57 -0
- package/dist/core/ids.js +88 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +14 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/logger.d.ts +100 -0
- package/dist/core/logger.js +207 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/paths.d.ts +84 -0
- package/dist/core/paths.js +138 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/template.d.ts +136 -0
- package/dist/core/template.js +261 -0
- package/dist/core/template.js.map +1 -0
- package/dist/core/types.d.ts +544 -0
- package/dist/core/types.js +172 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/worker-protocol.d.ts +102 -0
- package/dist/core/worker-protocol.js +75 -0
- package/dist/core/worker-protocol.js.map +1 -0
- package/dist/executors/classify.d.ts +25 -0
- package/dist/executors/classify.js +54 -0
- package/dist/executors/classify.js.map +1 -0
- package/dist/executors/dispatcher.d.ts +35 -0
- package/dist/executors/dispatcher.js +68 -0
- package/dist/executors/dispatcher.js.map +1 -0
- package/dist/executors/env.d.ts +43 -0
- package/dist/executors/env.js +66 -0
- package/dist/executors/env.js.map +1 -0
- package/dist/executors/event-parser.d.ts +41 -0
- package/dist/executors/event-parser.js +106 -0
- package/dist/executors/event-parser.js.map +1 -0
- package/dist/executors/index.d.ts +12 -0
- package/dist/executors/index.js +13 -0
- package/dist/executors/index.js.map +1 -0
- package/dist/executors/log-capture.d.ts +57 -0
- package/dist/executors/log-capture.js +155 -0
- package/dist/executors/log-capture.js.map +1 -0
- package/dist/executors/process-identity.d.ts +124 -0
- package/dist/executors/process-identity.js +283 -0
- package/dist/executors/process-identity.js.map +1 -0
- package/dist/executors/shell-executor.d.ts +35 -0
- package/dist/executors/shell-executor.js +478 -0
- package/dist/executors/shell-executor.js.map +1 -0
- package/dist/executors/stream-redactor.d.ts +40 -0
- package/dist/executors/stream-redactor.js +89 -0
- package/dist/executors/stream-redactor.js.map +1 -0
- package/dist/executors/workspace.d.ts +10 -0
- package/dist/executors/workspace.js +47 -0
- package/dist/executors/workspace.js.map +1 -0
- package/dist/filters/evaluate.d.ts +77 -0
- package/dist/filters/evaluate.js +187 -0
- package/dist/filters/evaluate.js.map +1 -0
- package/dist/filters/fields.d.ts +35 -0
- package/dist/filters/fields.js +75 -0
- package/dist/filters/fields.js.map +1 -0
- package/dist/filters/index.d.ts +11 -0
- package/dist/filters/index.js +12 -0
- package/dist/filters/index.js.map +1 -0
- package/dist/filters/iso-date.d.ts +10 -0
- package/dist/filters/iso-date.js +42 -0
- package/dist/filters/iso-date.js.map +1 -0
- package/dist/filters/predicate.d.ts +24 -0
- package/dist/filters/predicate.js +178 -0
- package/dist/filters/predicate.js.map +1 -0
- package/dist/filters/priority.d.ts +51 -0
- package/dist/filters/priority.js +91 -0
- package/dist/filters/priority.js.map +1 -0
- package/dist/filters/pushdown.d.ts +12 -0
- package/dist/filters/pushdown.js +52 -0
- package/dist/filters/pushdown.js.map +1 -0
- package/dist/filters/shell-check.d.ts +110 -0
- package/dist/filters/shell-check.js +304 -0
- package/dist/filters/shell-check.js.map +1 -0
- package/dist/identities/github-app.d.ts +55 -0
- package/dist/identities/github-app.js +160 -0
- package/dist/identities/github-app.js.map +1 -0
- package/dist/identities/index.d.ts +6 -0
- package/dist/identities/index.js +7 -0
- package/dist/identities/index.js.map +1 -0
- package/dist/identities/jwt.d.ts +24 -0
- package/dist/identities/jwt.js +56 -0
- package/dist/identities/jwt.js.map +1 -0
- package/dist/lifecycle/context.d.ts +106 -0
- package/dist/lifecycle/context.js +170 -0
- package/dist/lifecycle/context.js.map +1 -0
- package/dist/lifecycle/engine.d.ts +185 -0
- package/dist/lifecycle/engine.js +551 -0
- package/dist/lifecycle/engine.js.map +1 -0
- package/dist/lifecycle/errors.d.ts +48 -0
- package/dist/lifecycle/errors.js +71 -0
- package/dist/lifecycle/errors.js.map +1 -0
- package/dist/lifecycle/index.d.ts +9 -0
- package/dist/lifecycle/index.js +9 -0
- package/dist/lifecycle/index.js.map +1 -0
- package/dist/lifecycle/performers.d.ts +66 -0
- package/dist/lifecycle/performers.js +179 -0
- package/dist/lifecycle/performers.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.js +7 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/service.d.ts +115 -0
- package/dist/notifications/service.js +364 -0
- package/dist/notifications/service.js.map +1 -0
- package/dist/persistence/actions.d.ts +101 -0
- package/dist/persistence/actions.js +196 -0
- package/dist/persistence/actions.js.map +1 -0
- package/dist/persistence/blocks.d.ts +85 -0
- package/dist/persistence/blocks.js +140 -0
- package/dist/persistence/blocks.js.map +1 -0
- package/dist/persistence/cron-firings.d.ts +55 -0
- package/dist/persistence/cron-firings.js +70 -0
- package/dist/persistence/cron-firings.js.map +1 -0
- package/dist/persistence/database.d.ts +60 -0
- package/dist/persistence/database.js +124 -0
- package/dist/persistence/database.js.map +1 -0
- package/dist/persistence/errors.d.ts +39 -0
- package/dist/persistence/errors.js +67 -0
- package/dist/persistence/errors.js.map +1 -0
- package/dist/persistence/events.d.ts +72 -0
- package/dist/persistence/events.js +83 -0
- package/dist/persistence/events.js.map +1 -0
- package/dist/persistence/index.d.ts +18 -0
- package/dist/persistence/index.js +19 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/instance-lock.d.ts +26 -0
- package/dist/persistence/instance-lock.js +117 -0
- package/dist/persistence/instance-lock.js.map +1 -0
- package/dist/persistence/lock-keys.d.ts +16 -0
- package/dist/persistence/lock-keys.js +41 -0
- package/dist/persistence/lock-keys.js.map +1 -0
- package/dist/persistence/notifications.d.ts +113 -0
- package/dist/persistence/notifications.js +213 -0
- package/dist/persistence/notifications.js.map +1 -0
- package/dist/persistence/persistence.d.ts +46 -0
- package/dist/persistence/persistence.js +42 -0
- package/dist/persistence/persistence.js.map +1 -0
- package/dist/persistence/retention.d.ts +46 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/retention.js.map +1 -0
- package/dist/persistence/runs.d.ts +215 -0
- package/dist/persistence/runs.js +417 -0
- package/dist/persistence/runs.js.map +1 -0
- package/dist/persistence/schema.d.ts +16 -0
- package/dist/persistence/schema.js +183 -0
- package/dist/persistence/schema.js.map +1 -0
- package/dist/persistence/sqlite.d.ts +49 -0
- package/dist/persistence/sqlite.js +131 -0
- package/dist/persistence/sqlite.js.map +1 -0
- package/dist/runs/controller.d.ts +307 -0
- package/dist/runs/controller.js +1078 -0
- package/dist/runs/controller.js.map +1 -0
- package/dist/runs/errors.d.ts +20 -0
- package/dist/runs/errors.js +26 -0
- package/dist/runs/errors.js.map +1 -0
- package/dist/runs/event-bus.d.ts +106 -0
- package/dist/runs/event-bus.js +105 -0
- package/dist/runs/event-bus.js.map +1 -0
- package/dist/runs/execution.d.ts +39 -0
- package/dist/runs/execution.js +146 -0
- package/dist/runs/execution.js.map +1 -0
- package/dist/runs/index.d.ts +12 -0
- package/dist/runs/index.js +13 -0
- package/dist/runs/index.js.map +1 -0
- package/dist/runs/retention.d.ts +64 -0
- package/dist/runs/retention.js +232 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/workspace.d.ts +20 -0
- package/dist/runs/workspace.js +51 -0
- package/dist/runs/workspace.js.map +1 -0
- package/dist/scheduler/checks.d.ts +41 -0
- package/dist/scheduler/checks.js +111 -0
- package/dist/scheduler/checks.js.map +1 -0
- package/dist/scheduler/cron.d.ts +32 -0
- package/dist/scheduler/cron.js +83 -0
- package/dist/scheduler/cron.js.map +1 -0
- package/dist/scheduler/dry-run.d.ts +42 -0
- package/dist/scheduler/dry-run.js +167 -0
- package/dist/scheduler/dry-run.js.map +1 -0
- package/dist/scheduler/errors.d.ts +22 -0
- package/dist/scheduler/errors.js +22 -0
- package/dist/scheduler/errors.js.map +1 -0
- package/dist/scheduler/format.d.ts +3 -0
- package/dist/scheduler/format.js +73 -0
- package/dist/scheduler/format.js.map +1 -0
- package/dist/scheduler/index.d.ts +13 -0
- package/dist/scheduler/index.js +14 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/planner.d.ts +88 -0
- package/dist/scheduler/planner.js +255 -0
- package/dist/scheduler/planner.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +160 -0
- package/dist/scheduler/scheduler.js +680 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/dist/scheduler/state.d.ts +26 -0
- package/dist/scheduler/state.js +25 -0
- package/dist/scheduler/state.js.map +1 -0
- package/dist/scheduler/types.d.ts +197 -0
- package/dist/scheduler/types.js +3 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/service/index.d.ts +6 -0
- package/dist/service/index.js +7 -0
- package/dist/service/index.js.map +1 -0
- package/dist/service/install.d.ts +93 -0
- package/dist/service/install.js +250 -0
- package/dist/service/install.js.map +1 -0
- package/dist/service/unit.d.ts +50 -0
- package/dist/service/unit.js +171 -0
- package/dist/service/unit.js.map +1 -0
- package/dist/sources/github/adapter.d.ts +99 -0
- package/dist/sources/github/adapter.js +336 -0
- package/dist/sources/github/adapter.js.map +1 -0
- package/dist/sources/github/fields.d.ts +8 -0
- package/dist/sources/github/fields.js +72 -0
- package/dist/sources/github/fields.js.map +1 -0
- package/dist/sources/github/http.d.ts +100 -0
- package/dist/sources/github/http.js +288 -0
- package/dist/sources/github/http.js.map +1 -0
- package/dist/sources/github/index.d.ts +6 -0
- package/dist/sources/github/index.js +7 -0
- package/dist/sources/github/index.js.map +1 -0
- package/dist/sources/github/pushdown.d.ts +14 -0
- package/dist/sources/github/pushdown.js +47 -0
- package/dist/sources/github/pushdown.js.map +1 -0
- package/dist/sources/github/refs.d.ts +17 -0
- package/dist/sources/github/refs.js +61 -0
- package/dist/sources/github/refs.js.map +1 -0
- package/dist/sources/index.d.ts +6 -0
- package/dist/sources/index.js +7 -0
- package/dist/sources/index.js.map +1 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.js +28 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/public/app.css +239 -0
- package/dist/ui/public/favicon.svg +1 -0
- package/dist/ui/public/index.html +27 -0
- package/dist/ui/public/js/client/api.js +68 -0
- package/dist/ui/public/js/client/app.js +146 -0
- package/dist/ui/public/js/client/dom.js +34 -0
- package/dist/ui/public/js/client/page.js +9 -0
- package/dist/ui/public/js/client/pages/actions.js +35 -0
- package/dist/ui/public/js/client/pages/blocked.js +58 -0
- package/dist/ui/public/js/client/pages/config.js +126 -0
- package/dist/ui/public/js/client/pages/dashboard.js +175 -0
- package/dist/ui/public/js/client/pages/run.js +367 -0
- package/dist/ui/public/js/client/pages/runs.js +72 -0
- package/dist/ui/public/js/client/pages/views.js +80 -0
- package/dist/ui/public/js/client/pages/workflows.js +291 -0
- package/dist/ui/public/js/client/types.js +1 -0
- package/dist/ui/public/js/client/ui.js +152 -0
- package/dist/ui/public/js/shared/dom.js +77 -0
- package/dist/ui/public/js/shared/events.js +88 -0
- package/dist/ui/public/js/shared/format.js +213 -0
- package/dist/ui/public/js/shared/logs.js +136 -0
- package/dist/ui/public/js/shared/params.js +90 -0
- package/dist/ui/public/js/shared/route.js +36 -0
- package/dist/ui/public/js/shared/sort.js +70 -0
- package/dist/ui/public/js/shared/sse.js +193 -0
- package/dist/ui/public/js/shared/throttle.js +65 -0
- package/dist/views/index.d.ts +6 -0
- package/dist/views/index.js +7 -0
- package/dist/views/index.js.map +1 -0
- package/dist/views/service.d.ts +131 -0
- package/dist/views/service.js +264 -0
- package/dist/views/service.js.map +1 -0
- package/docs/api.md +239 -0
- package/docs/configuration-reference.md +1785 -0
- package/docs/cron-semantics.md +96 -0
- package/docs/manual-tests/github-fixture/config.yaml +30 -0
- package/docs/manual-tests/github-fixture/helpers.sh +93 -0
- package/docs/manual-tests/github-fixture/identities.yaml +18 -0
- package/docs/manual-tests/github-fixture/scripts/test-worker.sh +100 -0
- package/docs/manual-tests/github-fixture/sources.yaml +13 -0
- package/docs/manual-tests/github-fixture/views/dependencies.yaml +17 -0
- package/docs/manual-tests/github-fixture/views/needs-maintainer.yaml +17 -0
- package/docs/manual-tests/github-fixture/workflows/coder-test.yaml +76 -0
- package/docs/manual-tests/github-fixture/workflows/commenter-test.yaml +63 -0
- package/docs/manual-tests/github-fixture/workflows/reviewer-test.yaml +60 -0
- package/docs/manual-tests/github.md +1308 -0
- package/docs/manual-tests/process-supervision.md +64 -0
- package/docs/manual-tests/systemd.md +68 -0
- package/docs/operations.md +271 -0
- package/docs/worker-protocol.md +145 -0
- package/examples/config/config.yaml +35 -0
- package/examples/config/identities.yaml +18 -0
- package/examples/config/sources.yaml +13 -0
- package/examples/config/views/needs-maintainer.yaml +15 -0
- package/examples/config/workflows/coder-project-a.yaml +86 -0
- package/examples/config/workflows/deploy.yaml +15 -0
- package/examples/config/workflows/nightly-maintenance.yaml +13 -0
- package/package.json +75 -4
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP API (Spec §15) on Fastify: `/health`, `/ready` and `/api/v1/*`
|
|
3
|
+
* (documented in docs/api.md).
|
|
4
|
+
*
|
|
5
|
+
* Security (Spec §15, §20): no built-in authentication – bind to loopback
|
|
6
|
+
* (default) or run behind an authenticating proxy/VPN. Mutating requests
|
|
7
|
+
* (POST/PATCH/PUT/DELETE) require the header `X-Adispatch-Request: 1`,
|
|
8
|
+
* a JSON body (or none), and – if the browser sends an `Origin` – an origin
|
|
9
|
+
* matching the `Host` (or an allowed origin); cross-site fetches
|
|
10
|
+
* (`Sec-Fetch-Site: cross-site`) are refused. No CORS headers are ever sent,
|
|
11
|
+
* so browsers cannot read API responses cross-origin and the custom header
|
|
12
|
+
* forces a (failing) preflight for cross-origin writes.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import Fastify, {} from 'fastify';
|
|
18
|
+
import { isMap, isPair, isScalar, isSeq, parseDocument, visit } from 'yaml';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { ACTIVE_STATUSES, TECHNICAL_STATUSES, dataPaths, errorMessage, isSecretKey, isValidRunId, noopLogger, REDACTED, redactValue, systemClock, } from '../core/index.js';
|
|
21
|
+
import { loadConfig, toPublicConfig } from '../config/index.js';
|
|
22
|
+
import { ApiError, toErrorResponse } from './errors.js';
|
|
23
|
+
import { readLogPage } from './logs.js';
|
|
24
|
+
import { runDetailFields, toBlockView, toRunSummary, toWorkflowView } from './serialize.js';
|
|
25
|
+
/** Header that every mutating request must carry (CSRF protection). */
|
|
26
|
+
export const CSRF_HEADER = 'x-adispatch-request';
|
|
27
|
+
/** Max size of `context.json`/`result.json` returned in run details. */
|
|
28
|
+
const MAX_JSON_FILE_BYTES = 1024 * 1024;
|
|
29
|
+
/** Max events embedded in the run detail (more via `/runs/:id/events`). */
|
|
30
|
+
const DETAIL_EVENT_LIMIT = 500;
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Request schemas (zod)
|
|
33
|
+
const idParam = z.object({ id: z.string().min(1).max(200) });
|
|
34
|
+
const numericIdParam = z.object({ id: z.coerce.number().int().positive() });
|
|
35
|
+
const nullableOverride = (schema) => schema.nullable().optional();
|
|
36
|
+
const runtimeBody = z
|
|
37
|
+
.object({
|
|
38
|
+
enabled: nullableOverride(z.boolean()),
|
|
39
|
+
priority: nullableOverride(z.number().finite()),
|
|
40
|
+
maxConcurrent: nullableOverride(z.number().int().min(1)),
|
|
41
|
+
})
|
|
42
|
+
.strict();
|
|
43
|
+
const globalRuntimeBody = z.object({ maxConcurrent: z.number().int().min(1).nullable() }).strict();
|
|
44
|
+
const startBody = z
|
|
45
|
+
.object({
|
|
46
|
+
item: z.string().min(1).max(2000).optional(),
|
|
47
|
+
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const cancelBody = z.object({ reason: z.string().max(500).optional() }).strict();
|
|
51
|
+
const recoveryBody = z.object({ assumeGone: z.boolean().optional() }).strict();
|
|
52
|
+
const clearBody = z.object({ clearedBy: z.string().max(200).optional() }).strict();
|
|
53
|
+
const emptyBody = z.object({}).strict();
|
|
54
|
+
const runsQuery = z
|
|
55
|
+
.object({
|
|
56
|
+
workflow: z.string().optional(),
|
|
57
|
+
status: z.string().optional(),
|
|
58
|
+
trigger: z.enum(['source', 'schedule', 'manual']).optional(),
|
|
59
|
+
item: z.string().optional(),
|
|
60
|
+
result: z.enum(['success', 'failure']).optional(),
|
|
61
|
+
active: z.enum(['true', 'false']).optional(),
|
|
62
|
+
before: z.string().datetime({ offset: true }).optional(),
|
|
63
|
+
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
64
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
65
|
+
})
|
|
66
|
+
.strict();
|
|
67
|
+
const eventsQuery = z
|
|
68
|
+
.object({ after: z.coerce.number().int().min(0).optional(), limit: z.coerce.number().int().min(1).max(5000).optional() })
|
|
69
|
+
.strict();
|
|
70
|
+
const logsQuery = z
|
|
71
|
+
.object({
|
|
72
|
+
stream: z.enum(['stdout', 'stderr']).default('stdout'),
|
|
73
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
74
|
+
limit: z.coerce.number().int().min(1).optional(),
|
|
75
|
+
tail: z.coerce.number().int().min(0).optional(),
|
|
76
|
+
})
|
|
77
|
+
.strict();
|
|
78
|
+
const sseQuery = z
|
|
79
|
+
.object({ run: z.string().optional(), lastEventId: z.coerce.number().int().min(0).optional() })
|
|
80
|
+
.strict();
|
|
81
|
+
function parse(schema, value, what) {
|
|
82
|
+
const result = schema.safeParse(value ?? {});
|
|
83
|
+
if (!result.success) {
|
|
84
|
+
throw new ApiError(400, 'invalid_request', `Invalid ${what}`, {
|
|
85
|
+
issues: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return result.data;
|
|
89
|
+
}
|
|
90
|
+
/** Wait one macrotask: errors thrown synchronously at the start of `p` (validation, busy) surface here. */
|
|
91
|
+
async function earlyError(p) {
|
|
92
|
+
let failed = false;
|
|
93
|
+
let error;
|
|
94
|
+
p.catch((err) => {
|
|
95
|
+
failed = true;
|
|
96
|
+
error = err;
|
|
97
|
+
});
|
|
98
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
99
|
+
return failed ? (error ?? new Error('failed')) : undefined;
|
|
100
|
+
}
|
|
101
|
+
/** Read a JSON file (bounded); returns `{value}` or `{error}`; `null` if missing. */
|
|
102
|
+
async function readJsonFile(file) {
|
|
103
|
+
try {
|
|
104
|
+
const st = await stat(file);
|
|
105
|
+
if (!st.isFile())
|
|
106
|
+
return { error: 'not a regular file' };
|
|
107
|
+
if (st.size > MAX_JSON_FILE_BYTES)
|
|
108
|
+
return { error: `file too large (${st.size} bytes)` };
|
|
109
|
+
return { value: JSON.parse(await readFile(file, 'utf8')) };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err.code === 'ENOENT')
|
|
113
|
+
return null;
|
|
114
|
+
return { error: errorMessage(err) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function fileSize(file) {
|
|
118
|
+
try {
|
|
119
|
+
const st = await stat(file);
|
|
120
|
+
return st.isFile() ? st.size : null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Host names always accepted (loopback access to the local instance). */
|
|
127
|
+
const LOOPBACK_HOST_NAMES = ['localhost', '127.0.0.1', '[::1]'];
|
|
128
|
+
/** Split `name[:port]` / `[v6][:port]` into lower-case name and port (`null` = none). */
|
|
129
|
+
function splitHost(value) {
|
|
130
|
+
const v = value.trim().toLowerCase();
|
|
131
|
+
const m = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::(\d{1,5}))?$/.exec(v);
|
|
132
|
+
if (!m)
|
|
133
|
+
return null;
|
|
134
|
+
return { name: m[1], port: m[2] ?? null };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* True if the `Host` (or an `Origin`'s host) matches an allowed entry: `name`
|
|
138
|
+
* matches any port, `name:port` only that port. A value without port (the
|
|
139
|
+
* client used the scheme's default port, e.g. behind an HTTPS reverse proxy)
|
|
140
|
+
* matches entries without port and entries with `:80` or `:443`.
|
|
141
|
+
*/
|
|
142
|
+
export function isAllowedHost(value, allowed) {
|
|
143
|
+
const h = splitHost(value);
|
|
144
|
+
if (!h)
|
|
145
|
+
return false;
|
|
146
|
+
return allowed.some((entry) => {
|
|
147
|
+
const e = splitHost(entry);
|
|
148
|
+
if (!e || e.name !== h.name)
|
|
149
|
+
return false;
|
|
150
|
+
if (e.port === null)
|
|
151
|
+
return true;
|
|
152
|
+
return h.port === null ? e.port === '80' || e.port === '443' : e.port === h.port;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* True if `origin` (an http(s) origin) names exactly `host` (the request's
|
|
157
|
+
* `Host` header). A `Host` without port matches the origin scheme's default
|
|
158
|
+
* port (80/443), e.g. behind a TLS-terminating reverse proxy.
|
|
159
|
+
*/
|
|
160
|
+
export function originMatchesHost(origin, host) {
|
|
161
|
+
let url;
|
|
162
|
+
try {
|
|
163
|
+
url = new URL(origin);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
169
|
+
return false;
|
|
170
|
+
const o = splitHost(url.host);
|
|
171
|
+
const h = splitHost(host);
|
|
172
|
+
if (!o || !h || o.name !== h.name)
|
|
173
|
+
return false;
|
|
174
|
+
const defaultPort = url.protocol === 'https:' ? '443' : '80';
|
|
175
|
+
return (o.port ?? defaultPort) === (h.port ?? defaultPort);
|
|
176
|
+
}
|
|
177
|
+
function isLoopbackHost(host) {
|
|
178
|
+
const h = host.replace(/^\[|\]$/g, '');
|
|
179
|
+
return h === 'localhost' || h === '::1' || h.startsWith('127.');
|
|
180
|
+
}
|
|
181
|
+
/** Create the Fastify instance with all routes (not listening yet). */
|
|
182
|
+
export async function createApiServer(deps) {
|
|
183
|
+
const logger = (deps.logger ?? noopLogger).child({ component: 'api' });
|
|
184
|
+
const clock = deps.clock ?? systemClock;
|
|
185
|
+
const redact = deps.redact ?? ((s) => s);
|
|
186
|
+
const repos = deps.repositories;
|
|
187
|
+
const app = Fastify({ logger: false, bodyLimit: 1024 * 1024, return503OnClosing: true });
|
|
188
|
+
// Only JSON bodies (an empty body is allowed); no text/plain or form parsers (CSRF "simple requests").
|
|
189
|
+
app.removeAllContentTypeParsers();
|
|
190
|
+
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
|
191
|
+
const text = typeof body === 'string' ? body : body.toString('utf8');
|
|
192
|
+
if (text.trim() === '')
|
|
193
|
+
return done(null, undefined);
|
|
194
|
+
try {
|
|
195
|
+
done(null, JSON.parse(text));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
done(new ApiError(400, 'invalid_json', 'Request body is not valid JSON'), undefined);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
// --- Security hooks -------------------------------------------------------
|
|
202
|
+
const allowedHosts = () => {
|
|
203
|
+
const bind = deps.info.host.toLowerCase();
|
|
204
|
+
const extra = deps.config.current.global.server.allowedHosts;
|
|
205
|
+
const bindName = bind.includes(':') && !bind.startsWith('[') ? `[${bind}]` : bind;
|
|
206
|
+
const wildcard = bind === '0.0.0.0' || bind === '::' || bind === '[::]';
|
|
207
|
+
return [...LOOPBACK_HOST_NAMES, ...(wildcard || isLoopbackHost(bind) ? [] : [bindName]), ...extra];
|
|
208
|
+
};
|
|
209
|
+
app.addHook('onRequest', async (req) => {
|
|
210
|
+
// DNS-rebinding protection for ALL requests (incl. GET, SSE, static UI): the Host header must name this server.
|
|
211
|
+
const host = req.headers.host;
|
|
212
|
+
if (typeof host !== 'string' || !isAllowedHost(host, allowedHosts())) {
|
|
213
|
+
throw new ApiError(421, 'host_not_allowed', 'Host header is not allowed (configure server.allowed_hosts for reverse proxies)');
|
|
214
|
+
}
|
|
215
|
+
const origin = req.headers.origin;
|
|
216
|
+
if (origin !== undefined) {
|
|
217
|
+
// Same-origin only: the Origin (scheme default port applied) must name exactly the requested host and port.
|
|
218
|
+
const allowed = (deps.allowedOrigins ?? []).includes(origin) || originMatchesHost(origin, host);
|
|
219
|
+
if (!allowed)
|
|
220
|
+
throw new ApiError(403, 'origin_not_allowed', 'Request origin is not allowed');
|
|
221
|
+
}
|
|
222
|
+
if (req.method === 'GET' || req.method === 'HEAD')
|
|
223
|
+
return;
|
|
224
|
+
if (req.method === 'OPTIONS')
|
|
225
|
+
throw new ApiError(405, 'method_not_allowed', 'CORS preflight is not supported');
|
|
226
|
+
if (req.headers[CSRF_HEADER] !== '1') {
|
|
227
|
+
throw new ApiError(403, 'csrf_header_missing', `Mutating requests require the header "X-Adispatch-Request: 1"`);
|
|
228
|
+
}
|
|
229
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') {
|
|
230
|
+
throw new ApiError(403, 'cross_site_request', 'Cross-site requests are not allowed');
|
|
231
|
+
}
|
|
232
|
+
const hasBody = Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined;
|
|
233
|
+
const type = String(req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase();
|
|
234
|
+
if (hasBody && type !== 'application/json') {
|
|
235
|
+
throw new ApiError(415, 'unsupported_media_type', 'Request bodies must be application/json');
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
app.addHook('onSend', async (req, reply, payload) => {
|
|
239
|
+
reply.header('x-content-type-options', 'nosniff');
|
|
240
|
+
reply.header('referrer-policy', 'no-referrer');
|
|
241
|
+
reply.header('x-frame-options', 'DENY');
|
|
242
|
+
if (req.url.startsWith('/api/') || req.url === '/health' || req.url === '/ready')
|
|
243
|
+
reply.header('cache-control', 'no-store');
|
|
244
|
+
return payload;
|
|
245
|
+
});
|
|
246
|
+
app.setErrorHandler((err, req, reply) => {
|
|
247
|
+
const { status, body } = toErrorResponse(err, redact);
|
|
248
|
+
if (status >= 500)
|
|
249
|
+
logger.error('request failed', { method: req.method, url: req.url, error: err });
|
|
250
|
+
else
|
|
251
|
+
logger.debug('request rejected', { method: req.method, url: req.url, code: body.error.code });
|
|
252
|
+
void reply.status(status).send(body);
|
|
253
|
+
});
|
|
254
|
+
app.setNotFoundHandler((req, reply) => {
|
|
255
|
+
void reply.status(404).send({ error: { code: 'not_found', message: `Route ${req.method} ${req.url.split('?')[0]} not found` } });
|
|
256
|
+
});
|
|
257
|
+
// --- Helpers --------------------------------------------------------------
|
|
258
|
+
const requireRun = (id) => {
|
|
259
|
+
const run = repos.runs.get(id);
|
|
260
|
+
if (!run)
|
|
261
|
+
throw new ApiError(404, 'not_found', `Run '${id}' not found`);
|
|
262
|
+
return run;
|
|
263
|
+
};
|
|
264
|
+
const accepted = (reply, body) => reply.status(202).send(body);
|
|
265
|
+
const runPaths = (id) => {
|
|
266
|
+
if (!isValidRunId(id))
|
|
267
|
+
return null;
|
|
268
|
+
return dataPaths(deps.dataDir).run(id);
|
|
269
|
+
};
|
|
270
|
+
// --- Health / readiness ---------------------------------------------------
|
|
271
|
+
app.get('/health', async () => ({ status: 'ok', time: clock.now().toISOString() }));
|
|
272
|
+
app.get('/ready', async (_req, reply) => {
|
|
273
|
+
const r = deps.readiness();
|
|
274
|
+
return reply.status(r.ready ? 200 : 503).send(r);
|
|
275
|
+
});
|
|
276
|
+
// --- Status / scheduler / shutdown ----------------------------------------
|
|
277
|
+
app.get('/api/v1/status', async () => statusBody());
|
|
278
|
+
function statusBody() {
|
|
279
|
+
const s = deps.controller.status;
|
|
280
|
+
const cfg = deps.config.current;
|
|
281
|
+
const byWorkflow = {};
|
|
282
|
+
for (const wf of deps.config.listWorkflows()) {
|
|
283
|
+
byWorkflow[wf.config.id] = { active: repos.runs.countActive(wf.config.id), max: wf.maxConcurrent };
|
|
284
|
+
}
|
|
285
|
+
const recoveryPending = repos.runs.list({ statuses: ['recovery_pending'], limit: 1 }).total;
|
|
286
|
+
const last = deps.scheduler.lastPlanningStep();
|
|
287
|
+
const overrides = deps.config.getOverrides();
|
|
288
|
+
const state = !s.ready ? 'recovering' : s.stopping ? 'stopping' : s.draining ? 'draining' : s.paused ? 'paused' : 'running';
|
|
289
|
+
const host = deps.info.host;
|
|
290
|
+
return {
|
|
291
|
+
version: deps.info.version,
|
|
292
|
+
startedAt: deps.info.startedAt,
|
|
293
|
+
time: clock.now().toISOString(),
|
|
294
|
+
readiness: deps.readiness(),
|
|
295
|
+
scheduler: {
|
|
296
|
+
state,
|
|
297
|
+
ready: s.ready,
|
|
298
|
+
paused: s.paused,
|
|
299
|
+
draining: s.draining,
|
|
300
|
+
stopping: s.stopping,
|
|
301
|
+
pollingIntervalMs: cfg.global.scheduler.pollingIntervalMs,
|
|
302
|
+
lastPlanningStep: last
|
|
303
|
+
? { startedAt: last.startedAt, finishedAt: last.finishedAt, skipped: last.skipped, startedRunIds: last.startedRunIds }
|
|
304
|
+
: null,
|
|
305
|
+
},
|
|
306
|
+
limits: {
|
|
307
|
+
maxConcurrent: deps.config.maxConcurrent,
|
|
308
|
+
configuredMaxConcurrent: cfg.global.scheduler.maxConcurrent,
|
|
309
|
+
override: overrides.global.maxConcurrent ?? null,
|
|
310
|
+
},
|
|
311
|
+
usage: {
|
|
312
|
+
activeRuns: repos.runs.countActive(),
|
|
313
|
+
supervisedRuns: s.activeRuns,
|
|
314
|
+
recoveryPending,
|
|
315
|
+
byWorkflow,
|
|
316
|
+
},
|
|
317
|
+
counts: {
|
|
318
|
+
activeBlocks: repos.blocks.listActive().length,
|
|
319
|
+
failedActions: repos.actions.listFailed().length,
|
|
320
|
+
notifications: repos.notifications.countByState(),
|
|
321
|
+
},
|
|
322
|
+
config: { version: cfg.version, hash: cfg.hash, loadedAt: cfg.loadedAt, configDir: cfg.configDir },
|
|
323
|
+
server: { host, port: deps.info.port(), loopback: isLoopbackHost(host) },
|
|
324
|
+
sse: { clients: deps.events.clientCount },
|
|
325
|
+
retention: deps.retention?.lastReport
|
|
326
|
+
? {
|
|
327
|
+
lastRunAt: deps.retention.lastReport.finishedAt,
|
|
328
|
+
deletedRuns: deps.retention.lastReport.deletedRuns,
|
|
329
|
+
errors: deps.retention.lastReport.errors.length,
|
|
330
|
+
}
|
|
331
|
+
: null,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
app.post('/api/v1/scheduler/pause', async (req) => {
|
|
335
|
+
parse(emptyBody, req.body, 'body');
|
|
336
|
+
deps.controller.pause();
|
|
337
|
+
return statusBody();
|
|
338
|
+
});
|
|
339
|
+
app.post('/api/v1/scheduler/resume', async (req) => {
|
|
340
|
+
parse(emptyBody, req.body, 'body');
|
|
341
|
+
deps.controller.resume();
|
|
342
|
+
void deps.scheduler.planOnce().catch((err) => logger.warn('planning after resume failed', { error: errorMessage(err) }));
|
|
343
|
+
return statusBody();
|
|
344
|
+
});
|
|
345
|
+
app.patch('/api/v1/scheduler/runtime', async (req) => {
|
|
346
|
+
const body = parse(globalRuntimeBody, req.body, 'body');
|
|
347
|
+
deps.config.setGlobalMaxConcurrent(body.maxConcurrent ?? undefined);
|
|
348
|
+
void deps.scheduler.planOnce().catch(() => undefined);
|
|
349
|
+
return statusBody();
|
|
350
|
+
});
|
|
351
|
+
app.post('/api/v1/shutdown/drain', async (req, reply) => {
|
|
352
|
+
parse(emptyBody, req.body, 'body');
|
|
353
|
+
deps.shutdown('drain');
|
|
354
|
+
return accepted(reply, { accepted: true, mode: 'drain', activeRuns: deps.controller.activeRunIds() });
|
|
355
|
+
});
|
|
356
|
+
app.post('/api/v1/shutdown/cancel-all', async (req, reply) => {
|
|
357
|
+
parse(cancelBody, req.body, 'body');
|
|
358
|
+
const body = (req.body ?? {});
|
|
359
|
+
deps.shutdown('cancel', body.reason);
|
|
360
|
+
return accepted(reply, { accepted: true, mode: 'cancel', activeRuns: deps.controller.activeRunIds() });
|
|
361
|
+
});
|
|
362
|
+
// --- Workflows ------------------------------------------------------------
|
|
363
|
+
const workflowView = (id, detail) => {
|
|
364
|
+
const wf = deps.config.getWorkflow(id);
|
|
365
|
+
if (!wf)
|
|
366
|
+
throw new ApiError(404, 'config_unknown_workflow', `Unknown workflow "${id}"`);
|
|
367
|
+
const cron = deps.scheduler.cronStatus().find((c) => c.workflowId === id) ?? null;
|
|
368
|
+
const diagnosis = deps.scheduler.lastDiagnosis(id);
|
|
369
|
+
const view = toWorkflowView(wf, {
|
|
370
|
+
activeRuns: repos.runs.countActive(id),
|
|
371
|
+
activeBlocks: repos.blocks.listForWorkflow(id).filter((b) => b.clearedAt === null).length,
|
|
372
|
+
cron,
|
|
373
|
+
diagnosis,
|
|
374
|
+
});
|
|
375
|
+
return detail ? { ...view, diagnosis: diagnosis ?? null, filter: wf.config.filter, hooks: wf.config.hooks } : view;
|
|
376
|
+
};
|
|
377
|
+
app.get('/api/v1/workflows', async () => ({
|
|
378
|
+
workflows: deps.config.listWorkflows().map((wf) => workflowView(wf.config.id, false)),
|
|
379
|
+
overrides: deps.config.getOverrides(),
|
|
380
|
+
}));
|
|
381
|
+
app.get('/api/v1/workflows/:id', async (req) => workflowView(parse(idParam, req.params, 'path').id, true));
|
|
382
|
+
app.patch('/api/v1/workflows/:id/runtime', async (req) => {
|
|
383
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
384
|
+
const body = parse(runtimeBody, req.body, 'body');
|
|
385
|
+
if (!deps.config.getWorkflow(id))
|
|
386
|
+
throw new ApiError(404, 'config_unknown_workflow', `Unknown workflow "${id}"`);
|
|
387
|
+
const overrides = {};
|
|
388
|
+
for (const key of ['enabled', 'priority', 'maxConcurrent']) {
|
|
389
|
+
if (key in body)
|
|
390
|
+
overrides[key] = body[key] ?? undefined; // null resets to the YAML value
|
|
391
|
+
}
|
|
392
|
+
deps.config.setWorkflowOverrides(id, overrides);
|
|
393
|
+
void deps.scheduler.planOnce().catch(() => undefined);
|
|
394
|
+
return workflowView(id, false);
|
|
395
|
+
});
|
|
396
|
+
app.post('/api/v1/workflows/:id/start', async (req, reply) => {
|
|
397
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
398
|
+
const body = parse(startBody, req.body, 'body');
|
|
399
|
+
const result = await deps.scheduler.startManual(id, {
|
|
400
|
+
...(body.item !== undefined ? { itemRef: body.item } : {}),
|
|
401
|
+
...(body.parameters !== undefined ? { parameters: body.parameters } : {}),
|
|
402
|
+
});
|
|
403
|
+
result.done.catch((err) => logger.warn('manual run supervision failed', { runId: result.run.id, error: errorMessage(err) }));
|
|
404
|
+
return accepted(reply, { run: toRunSummary(repos.runs.get(result.run.id) ?? result.run) });
|
|
405
|
+
});
|
|
406
|
+
app.post('/api/v1/workflows/:id/dry-run', async (req) => {
|
|
407
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
408
|
+
parse(emptyBody, req.body, 'body');
|
|
409
|
+
if (!deps.config.getWorkflow(id))
|
|
410
|
+
throw new ApiError(404, 'config_unknown_workflow', `Unknown workflow "${id}"`);
|
|
411
|
+
return deps.scheduler.dryRun(id);
|
|
412
|
+
});
|
|
413
|
+
// --- Runs -----------------------------------------------------------------
|
|
414
|
+
app.get('/api/v1/runs', async (req) => {
|
|
415
|
+
const q = parse(runsQuery, req.query, 'query');
|
|
416
|
+
let statuses;
|
|
417
|
+
if (q.status) {
|
|
418
|
+
statuses = q.status.split(',').map((s) => s.trim());
|
|
419
|
+
const invalid = statuses.filter((s) => !TECHNICAL_STATUSES.includes(s));
|
|
420
|
+
if (invalid.length > 0)
|
|
421
|
+
throw new ApiError(400, 'invalid_request', `Unknown status: ${invalid.join(', ')}`);
|
|
422
|
+
}
|
|
423
|
+
if (q.active === 'true')
|
|
424
|
+
statuses = statuses ? statuses.filter((s) => ACTIVE_STATUSES.includes(s)) : [...ACTIVE_STATUSES];
|
|
425
|
+
if (q.active === 'false') {
|
|
426
|
+
const terminal = TECHNICAL_STATUSES.filter((s) => !ACTIVE_STATUSES.includes(s));
|
|
427
|
+
statuses = statuses ? statuses.filter((s) => terminal.includes(s)) : [...terminal];
|
|
428
|
+
}
|
|
429
|
+
const limit = q.limit ?? 50;
|
|
430
|
+
const offset = q.offset ?? 0;
|
|
431
|
+
if (statuses && statuses.length === 0)
|
|
432
|
+
return { runs: [], total: 0, limit, offset };
|
|
433
|
+
const page = repos.runs.list({
|
|
434
|
+
...(q.workflow ? { workflowId: q.workflow } : {}),
|
|
435
|
+
...(statuses ? { statuses } : {}),
|
|
436
|
+
...(q.trigger ? { triggerType: q.trigger } : {}),
|
|
437
|
+
...(q.item ? { sourceItemKey: q.item } : {}),
|
|
438
|
+
...(q.result ? { businessResult: q.result } : {}),
|
|
439
|
+
...(q.before ? { createdBefore: q.before } : {}),
|
|
440
|
+
limit,
|
|
441
|
+
offset,
|
|
442
|
+
});
|
|
443
|
+
return { runs: page.runs.map(toRunSummary), total: page.total, limit, offset };
|
|
444
|
+
});
|
|
445
|
+
app.get('/api/v1/runs/:id', async (req) => {
|
|
446
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
447
|
+
const run = requireRun(id);
|
|
448
|
+
const paths = runPaths(id);
|
|
449
|
+
const [input, resultFile, stdoutSize, stderrSize] = paths
|
|
450
|
+
? await Promise.all([readJsonFile(paths.contextFile), readJsonFile(paths.resultFile), fileSize(paths.stdoutLog), fileSize(paths.stderrLog)])
|
|
451
|
+
: [null, null, null, null];
|
|
452
|
+
const actions = repos.actions.listForRun(id).map((a) => ({ ...a, attemptsDetail: repos.actions.listAttempts(a.id) }));
|
|
453
|
+
const allEvents = repos.events.listForRun(id);
|
|
454
|
+
const events = allEvents.slice(0, DETAIL_EVENT_LIMIT + 1);
|
|
455
|
+
const truncatedStreams = new Set(allEvents
|
|
456
|
+
.filter((e) => e.type === 'diagnostic.log_truncated')
|
|
457
|
+
.map((e) => String(e.payload.stream ?? e.payload.details?.stream ?? '')));
|
|
458
|
+
const block = repos.blocks.activeFor(run.workflowId, run.sourceItemKey);
|
|
459
|
+
return redactValue({
|
|
460
|
+
...toRunSummary(run),
|
|
461
|
+
...runDetailFields(run),
|
|
462
|
+
input: input && 'value' in input ? input.value : null,
|
|
463
|
+
inputError: input && 'error' in input ? input.error : null,
|
|
464
|
+
resultFile: resultFile && 'value' in resultFile ? resultFile.value : null,
|
|
465
|
+
resultFileError: resultFile && 'error' in resultFile ? resultFile.error : null,
|
|
466
|
+
actions,
|
|
467
|
+
events: events.slice(0, DETAIL_EVENT_LIMIT),
|
|
468
|
+
eventsTruncated: events.length > DETAIL_EVENT_LIMIT,
|
|
469
|
+
logs: {
|
|
470
|
+
stdout: { size: stdoutSize, truncated: truncatedStreams.has('stdout') },
|
|
471
|
+
stderr: { size: stderrSize, truncated: truncatedStreams.has('stderr') },
|
|
472
|
+
},
|
|
473
|
+
block: block ? toBlockView(block, { affectedRunIds: repos.blocks.affectedRunIds(block.id), sourceUrl: run.sourceUrl, workflowName: null }) : null,
|
|
474
|
+
supervised: deps.controller.activeRunIds().includes(id),
|
|
475
|
+
}, redact);
|
|
476
|
+
});
|
|
477
|
+
app.get('/api/v1/runs/:id/events', async (req) => {
|
|
478
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
479
|
+
const q = parse(eventsQuery, req.query, 'query');
|
|
480
|
+
requireRun(id);
|
|
481
|
+
const limit = q.limit ?? 1000;
|
|
482
|
+
const events = repos.events.listForRun(id, { afterSequence: q.after ?? 0, limit: limit + 1 });
|
|
483
|
+
const page = events.slice(0, limit);
|
|
484
|
+
return { events: page, hasMore: events.length > limit, nextAfter: page.at(-1)?.sequence ?? q.after ?? 0 };
|
|
485
|
+
});
|
|
486
|
+
app.get('/api/v1/runs/:id/logs', async (req) => {
|
|
487
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
488
|
+
const q = parse(logsQuery, req.query, 'query');
|
|
489
|
+
const run = requireRun(id);
|
|
490
|
+
const paths = runPaths(id);
|
|
491
|
+
if (!paths)
|
|
492
|
+
throw new ApiError(404, 'not_found', 'No logs for this run');
|
|
493
|
+
const file = q.stream === 'stdout' ? paths.stdoutLog : paths.stderrLog;
|
|
494
|
+
const page = await readLogPage(file, q.stream, {
|
|
495
|
+
...(q.offset !== undefined ? { offset: q.offset } : {}),
|
|
496
|
+
...(q.limit !== undefined ? { limit: q.limit } : {}),
|
|
497
|
+
...(q.tail !== undefined ? { tail: q.tail } : {}),
|
|
498
|
+
});
|
|
499
|
+
return { runId: id, active: ACTIVE_STATUSES.includes(run.status), ...page };
|
|
500
|
+
});
|
|
501
|
+
app.post('/api/v1/runs/:id/cancel', async (req, reply) => {
|
|
502
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
503
|
+
const body = parse(cancelBody, req.body, 'body');
|
|
504
|
+
requireRun(id);
|
|
505
|
+
const r = deps.controller.requestCancel(id, { reason: body.reason ?? 'cancelled via API' });
|
|
506
|
+
return accepted(reply, { runId: r.runId, status: r.status, run: toRunSummary(requireRun(id)) });
|
|
507
|
+
});
|
|
508
|
+
app.post('/api/v1/runs/:id/recovery/retry', async (req, reply) => {
|
|
509
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
510
|
+
const body = parse(recoveryBody, req.body, 'body');
|
|
511
|
+
requireRun(id);
|
|
512
|
+
const op = deps.background(`recovery retry ${id}`, () => deps.controller.retryRecovery(id, { assumeGone: body.assumeGone === true }));
|
|
513
|
+
const error = await earlyError(op);
|
|
514
|
+
if (error)
|
|
515
|
+
throw error;
|
|
516
|
+
return accepted(reply, { accepted: true, run: toRunSummary(requireRun(id)) });
|
|
517
|
+
});
|
|
518
|
+
// --- Blocks ---------------------------------------------------------------
|
|
519
|
+
const blockView = (blockId) => {
|
|
520
|
+
const block = repos.blocks.get(blockId);
|
|
521
|
+
if (!block)
|
|
522
|
+
throw new ApiError(404, 'not_found', `Block ${blockId} not found`);
|
|
523
|
+
const run = block.runId ? repos.runs.get(block.runId) : null;
|
|
524
|
+
const wf = deps.config.getWorkflow(block.workflowId);
|
|
525
|
+
return toBlockView(block, {
|
|
526
|
+
affectedRunIds: repos.blocks.affectedRunIds(block.id),
|
|
527
|
+
sourceUrl: run?.sourceUrl ?? null,
|
|
528
|
+
workflowName: wf?.config.name ?? null,
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
app.get('/api/v1/blocks', async (req) => {
|
|
532
|
+
const q = parse(z.object({ workflow: z.string().optional() }).strict(), req.query, 'query');
|
|
533
|
+
const blocks = repos.blocks.listActive().filter((b) => !q.workflow || b.workflowId === q.workflow);
|
|
534
|
+
return { blocks: blocks.map((b) => blockView(b.id)) };
|
|
535
|
+
});
|
|
536
|
+
app.post('/api/v1/blocks/:id/clear', async (req) => {
|
|
537
|
+
const { id } = parse(numericIdParam, req.params, 'path');
|
|
538
|
+
const body = parse(clearBody, req.body, 'body');
|
|
539
|
+
if (!repos.blocks.get(id))
|
|
540
|
+
throw new ApiError(404, 'not_found', `Block ${id} not found`);
|
|
541
|
+
deps.controller.clearBlock(id, body.clearedBy ?? 'api');
|
|
542
|
+
void deps.scheduler.planOnce().catch(() => undefined);
|
|
543
|
+
return { block: blockView(id) };
|
|
544
|
+
});
|
|
545
|
+
// --- Lifecycle actions ----------------------------------------------------
|
|
546
|
+
app.get('/api/v1/actions/failed', async () => ({
|
|
547
|
+
actions: repos.actions.listFailed().map((a) => {
|
|
548
|
+
const run = repos.runs.get(a.runId);
|
|
549
|
+
return {
|
|
550
|
+
...a,
|
|
551
|
+
attemptsDetail: repos.actions.listAttempts(a.id),
|
|
552
|
+
run: run ? toRunSummary(run) : null,
|
|
553
|
+
busy: deps.lifecycle.isBusy(a.runId),
|
|
554
|
+
};
|
|
555
|
+
}),
|
|
556
|
+
}));
|
|
557
|
+
const actionOp = async (req, reply, kind) => {
|
|
558
|
+
const { id } = parse(numericIdParam, req.params, 'path');
|
|
559
|
+
parse(emptyBody, req.body, 'body');
|
|
560
|
+
const action = repos.actions.get(id);
|
|
561
|
+
if (!action)
|
|
562
|
+
throw new ApiError(404, 'not_found', `Action ${id} not found`);
|
|
563
|
+
const op = deps.background(`action ${kind} ${id}`, (signal) => kind === 'retry' ? deps.lifecycle.retryAction(id, { signal }) : deps.lifecycle.skipAction(id, { signal }));
|
|
564
|
+
const error = await earlyError(op);
|
|
565
|
+
if (error)
|
|
566
|
+
throw error;
|
|
567
|
+
return accepted(reply, { accepted: true, action: repos.actions.get(id) });
|
|
568
|
+
};
|
|
569
|
+
app.post('/api/v1/actions/:id/retry', async (req, reply) => actionOp(req, reply, 'retry'));
|
|
570
|
+
app.post('/api/v1/actions/:id/skip', async (req, reply) => actionOp(req, reply, 'skip'));
|
|
571
|
+
// --- Views ----------------------------------------------------------------
|
|
572
|
+
app.get('/api/v1/views', async () => ({ views: deps.views.list() }));
|
|
573
|
+
app.get('/api/v1/views/:id', async (req) => {
|
|
574
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
575
|
+
const view = deps.views.get(id);
|
|
576
|
+
if (!view)
|
|
577
|
+
throw new ApiError(404, 'view_not_found', `Unknown view "${id}"`);
|
|
578
|
+
return view;
|
|
579
|
+
});
|
|
580
|
+
app.get('/api/v1/views/:id/items', async (req) => {
|
|
581
|
+
const { id } = parse(idParam, req.params, 'path');
|
|
582
|
+
const q = parse(z.object({ refresh: z.enum(['true', 'false', '1', '0']).optional() }).strict(), req.query, 'query');
|
|
583
|
+
const result = await deps.views.items(id, { refresh: q.refresh === 'true' || q.refresh === '1' });
|
|
584
|
+
return { ...result, errors: result.errors.map((e) => ({ sourceId: e.sourceId, message: redact(e.message) })) };
|
|
585
|
+
});
|
|
586
|
+
// --- Configuration --------------------------------------------------------
|
|
587
|
+
app.get('/api/v1/config', async () => {
|
|
588
|
+
const cfg = toPublicConfig(deps.config.current);
|
|
589
|
+
return { config: cfg, overrides: deps.config.getOverrides() };
|
|
590
|
+
});
|
|
591
|
+
app.get('/api/v1/config/files', async () => ({ files: await readConfigFiles(deps.config.current.configDir, redact) }));
|
|
592
|
+
app.post('/api/v1/config/validate', async (req) => {
|
|
593
|
+
parse(emptyBody, req.body, 'body');
|
|
594
|
+
const res = await loadConfig(deps.config.current.configDir, { dataDir: deps.dataDir, version: deps.config.current.version + 1 });
|
|
595
|
+
return res.ok ? { ok: true, errors: [], warnings: res.warnings, hash: res.config.hash } : { ok: false, errors: res.errors, warnings: res.warnings };
|
|
596
|
+
});
|
|
597
|
+
app.post('/api/v1/config/reload', async (req, reply) => {
|
|
598
|
+
parse(emptyBody, req.body, 'body');
|
|
599
|
+
const res = await deps.reloadConfig();
|
|
600
|
+
if (!res.ok) {
|
|
601
|
+
return reply.status(422).send({
|
|
602
|
+
error: { code: 'config_invalid', message: `Configuration is invalid (${res.errors.length} error(s)); the active configuration was kept`, details: { errors: res.errors, warnings: res.warnings } },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
version: res.config.version,
|
|
608
|
+
hash: res.config.hash,
|
|
609
|
+
changed: res.changed,
|
|
610
|
+
diff: res.diff,
|
|
611
|
+
resetOverrides: res.resetOverrides,
|
|
612
|
+
warnings: res.warnings,
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
// --- Notifications --------------------------------------------------------
|
|
616
|
+
app.get('/api/v1/notifications', async (req) => {
|
|
617
|
+
const q = parse(z
|
|
618
|
+
.object({
|
|
619
|
+
state: z.enum(['pending', 'delivered', 'failed']).optional(),
|
|
620
|
+
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
621
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
622
|
+
})
|
|
623
|
+
.strict(), req.query, 'query');
|
|
624
|
+
return {
|
|
625
|
+
notifications: deps.notifications.list({ ...(q.state ? { state: q.state } : {}), limit: q.limit ?? 100, offset: q.offset ?? 0 }),
|
|
626
|
+
counts: repos.notifications.countByState(),
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
app.post('/api/v1/notifications/:id/retry', async (req, reply) => {
|
|
630
|
+
const { id } = parse(numericIdParam, req.params, 'path');
|
|
631
|
+
parse(emptyBody, req.body, 'body');
|
|
632
|
+
const res = deps.notifications.retry(id);
|
|
633
|
+
if (!res)
|
|
634
|
+
throw new ApiError(404, 'not_found', `Notification ${id} not found`);
|
|
635
|
+
return accepted(reply, res);
|
|
636
|
+
});
|
|
637
|
+
// --- SSE --------------------------------------------------------------------
|
|
638
|
+
app.get('/api/v1/events', async (req, reply) => {
|
|
639
|
+
const q = parse(sseQuery, req.query, 'query');
|
|
640
|
+
const header = req.headers['last-event-id'];
|
|
641
|
+
const fromHeader = typeof header === 'string' && /^\d+$/.test(header.trim()) ? Number(header.trim()) : null;
|
|
642
|
+
const lastEventId = fromHeader ?? q.lastEventId ?? null;
|
|
643
|
+
if (q.run)
|
|
644
|
+
requireRun(q.run);
|
|
645
|
+
reply.hijack();
|
|
646
|
+
deps.events.handle(req.raw, reply.raw, { runId: q.run ?? null, lastEventId });
|
|
647
|
+
});
|
|
648
|
+
// --- Static web UI (WI-11) --------------------------------------------------
|
|
649
|
+
if (deps.uiDir && existsSync(deps.uiDir)) {
|
|
650
|
+
const fastifyStatic = (await import('@fastify/static')).default;
|
|
651
|
+
await app.register(fastifyStatic, { root: path.resolve(deps.uiDir), prefix: '/', index: ['index.html'] });
|
|
652
|
+
}
|
|
653
|
+
return app;
|
|
654
|
+
}
|
|
655
|
+
/** YAML files of the config dir (read-only display); literal secrets known to the redactor are masked. */
|
|
656
|
+
async function readConfigFiles(configDir, redact) {
|
|
657
|
+
const { readdir } = await import('node:fs/promises');
|
|
658
|
+
const files = [];
|
|
659
|
+
for (const top of ['config.yaml', 'sources.yaml', 'identities.yaml'])
|
|
660
|
+
if (existsSync(path.join(configDir, top)))
|
|
661
|
+
files.push(top);
|
|
662
|
+
for (const sub of ['workflows', 'views']) {
|
|
663
|
+
try {
|
|
664
|
+
const names = (await readdir(path.join(configDir, sub))).filter((n) => /\.ya?ml$/.test(n)).sort();
|
|
665
|
+
for (const n of names)
|
|
666
|
+
files.push(`${sub}/${n}`);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// directory missing
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return Promise.all(files.map(async (file) => {
|
|
673
|
+
try {
|
|
674
|
+
const st = await stat(path.join(configDir, file));
|
|
675
|
+
if (st.size > 1024 * 1024)
|
|
676
|
+
return { file, content: null, error: 'file too large' };
|
|
677
|
+
const masked = redactYaml(await readFile(path.join(configDir, file), 'utf8'));
|
|
678
|
+
if ('error' in masked)
|
|
679
|
+
return { file, content: null, error: masked.error };
|
|
680
|
+
return { file, content: redact(masked.content) };
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
return { file, content: null, error: errorMessage(err) };
|
|
684
|
+
}
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
/** Keys whose values are always masked in displayed YAML (besides {@link isSecretKey}). */
|
|
688
|
+
const MASKED_YAML_KEYS = new Set(['url']);
|
|
689
|
+
/** Keys whose whole sub-tree of values is masked (header values / header env names). */
|
|
690
|
+
const MASKED_YAML_SUBTREES = new Set(['headers', 'headers_env']);
|
|
691
|
+
/** `https://host/path?q` → `https://host/[REDACTED]` (only when something follows the host). */
|
|
692
|
+
function truncateUrl(value) {
|
|
693
|
+
return value.replace(/^(\s*https?:\/\/[^\s/?#]+)[^\s]*/i, (m, origin) => (m === origin ? m : `${origin}/${REDACTED}`));
|
|
694
|
+
}
|
|
695
|
+
function maskComment(text) {
|
|
696
|
+
if (!text)
|
|
697
|
+
return text;
|
|
698
|
+
// Keep the origin of URLs in comments, never path/query (tokens of webhook URLs).
|
|
699
|
+
return text.replace(/\b(https?:\/\/[^\s/]+)[^\s]*/gi, (_m, origin) => `${origin}/${REDACTED}`);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Structural redaction of a YAML file for read-only display: values of
|
|
703
|
+
* `url` keys, everything below `headers`/`headers_env` and every key that
|
|
704
|
+
* looks secret ({@link isSecretKey}) are replaced, URLs in comments are cut
|
|
705
|
+
* to their origin. Independent of the loaded configuration (also covers
|
|
706
|
+
* edited, not yet reloaded files). Unparseable files are never returned raw.
|
|
707
|
+
*/
|
|
708
|
+
export function redactYaml(text) {
|
|
709
|
+
const doc = parseDocument(text, { prettyErrors: false });
|
|
710
|
+
if (doc.errors.length > 0) {
|
|
711
|
+
const e = doc.errors[0];
|
|
712
|
+
const line = e.linePos?.[0]?.line;
|
|
713
|
+
const message = e.message.split(/:\s*\n/)[0] ?? 'invalid YAML';
|
|
714
|
+
return { error: `YAML cannot be displayed (syntax error${line ? ` at line ${line}` : ''}: ${message})` };
|
|
715
|
+
}
|
|
716
|
+
doc.commentBefore = maskComment(doc.commentBefore) ?? null;
|
|
717
|
+
doc.comment = maskComment(doc.comment) ?? null;
|
|
718
|
+
visit(doc, {
|
|
719
|
+
Node(_key, node) {
|
|
720
|
+
// Any URL value (e.g. a literal webhook in `env:`) is cut to scheme + host.
|
|
721
|
+
if (isScalar(node) && typeof node.value === 'string' && /^\s*https?:\/\//i.test(node.value)) {
|
|
722
|
+
node.value = truncateUrl(node.value);
|
|
723
|
+
}
|
|
724
|
+
const n = node;
|
|
725
|
+
if (n.comment)
|
|
726
|
+
n.comment = maskComment(n.comment) ?? null;
|
|
727
|
+
if (n.commentBefore)
|
|
728
|
+
n.commentBefore = maskComment(n.commentBefore) ?? null;
|
|
729
|
+
},
|
|
730
|
+
Pair(_key, pair, ancestors) {
|
|
731
|
+
const keyName = isScalar(pair.key) ? String(pair.key.value) : '';
|
|
732
|
+
const underMaskedSubtree = ancestors.some((a) => isPair(a) && isScalar(a.key) && MASKED_YAML_SUBTREES.has(String(a.key.value)));
|
|
733
|
+
const mask = MASKED_YAML_KEYS.has(keyName) || isSecretKey(keyName) || underMaskedSubtree || MASKED_YAML_SUBTREES.has(keyName);
|
|
734
|
+
if (!mask)
|
|
735
|
+
return;
|
|
736
|
+
const maskNode = (value) => {
|
|
737
|
+
if (isScalar(value)) {
|
|
738
|
+
if (value.value !== null && value.value !== '')
|
|
739
|
+
value.value = REDACTED;
|
|
740
|
+
}
|
|
741
|
+
else if (isMap(value) || isSeq(value)) {
|
|
742
|
+
for (const item of value.items)
|
|
743
|
+
maskNode(isPair(item) ? item.value : item);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
maskNode(pair.value);
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
return { content: doc.toString() };
|
|
750
|
+
}
|
|
751
|
+
//# sourceMappingURL=server.js.map
|