@omnitron-dev/omnitron 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/omnitron.js +32 -1
- package/dist/cli/omnitron.js.map +1 -1
- package/dist/cluster/leader-election.d.ts.map +1 -1
- package/dist/cluster/leader-election.js +0 -2
- package/dist/cluster/leader-election.js.map +1 -1
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +10 -2
- package/dist/commands/env.js.map +1 -1
- package/dist/commands/health.d.ts.map +1 -1
- package/dist/commands/health.js +10 -2
- package/dist/commands/health.js.map +1 -1
- package/dist/commands/inspect-graph.d.ts +20 -0
- package/dist/commands/inspect-graph.d.ts.map +1 -0
- package/dist/commands/inspect-graph.js +78 -0
- package/dist/commands/inspect-graph.js.map +1 -0
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +10 -2
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +11 -1
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +17 -3
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +10 -2
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/monit.d.ts.map +1 -1
- package/dist/commands/monit.js +9 -0
- package/dist/commands/monit.js.map +1 -1
- package/dist/commands/output.d.ts +35 -0
- package/dist/commands/output.d.ts.map +1 -0
- package/dist/commands/output.js +63 -0
- package/dist/commands/output.js.map +1 -0
- package/dist/commands/stack.d.ts.map +1 -1
- package/dist/commands/stack.js +22 -7
- package/dist/commands/stack.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +17 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/tor.d.ts +13 -0
- package/dist/commands/tor.d.ts.map +1 -0
- package/dist/commands/tor.js +100 -0
- package/dist/commands/tor.js.map +1 -0
- package/dist/commands/up.js +1 -1
- package/dist/commands/up.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +29 -5
- package/dist/config/loader.js.map +1 -1
- package/dist/config/types.d.ts +14 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/daemon/daemon-client.d.ts +14 -0
- package/dist/daemon/daemon-client.d.ts.map +1 -1
- package/dist/daemon/daemon-client.js +4 -0
- package/dist/daemon/daemon-client.js.map +1 -1
- package/dist/daemon/daemon-entry.js +17 -23
- package/dist/daemon/daemon-entry.js.map +1 -1
- package/dist/daemon/daemon.d.ts +2 -0
- package/dist/daemon/daemon.d.ts.map +1 -1
- package/dist/daemon/daemon.js +118 -7
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/daemon/daemon.module.d.ts.map +1 -1
- package/dist/daemon/daemon.module.js +41 -14
- package/dist/daemon/daemon.module.js.map +1 -1
- package/dist/daemon/daemon.rpc-service.d.ts +19 -0
- package/dist/daemon/daemon.rpc-service.d.ts.map +1 -1
- package/dist/daemon/daemon.rpc-service.js +14 -0
- package/dist/daemon/daemon.rpc-service.js.map +1 -1
- package/dist/daemon/pid-manager.d.ts +55 -4
- package/dist/daemon/pid-manager.d.ts.map +1 -1
- package/dist/daemon/pid-manager.js +123 -16
- package/dist/daemon/pid-manager.js.map +1 -1
- package/dist/daemon/state-store.d.ts.map +1 -1
- package/dist/daemon/state-store.js +43 -1
- package/dist/daemon/state-store.js.map +1 -1
- package/dist/infrastructure/container-runtime.d.ts +10 -0
- package/dist/infrastructure/container-runtime.d.ts.map +1 -1
- package/dist/infrastructure/container-runtime.js +111 -36
- package/dist/infrastructure/container-runtime.js.map +1 -1
- package/dist/infrastructure/infrastructure.service.d.ts +41 -0
- package/dist/infrastructure/infrastructure.service.d.ts.map +1 -1
- package/dist/infrastructure/infrastructure.service.js +157 -8
- package/dist/infrastructure/infrastructure.service.js.map +1 -1
- package/dist/infrastructure/phantom-endpoint-janitor.d.ts +72 -0
- package/dist/infrastructure/phantom-endpoint-janitor.d.ts.map +1 -0
- package/dist/infrastructure/phantom-endpoint-janitor.js +149 -0
- package/dist/infrastructure/phantom-endpoint-janitor.js.map +1 -0
- package/dist/infrastructure/presets/index.d.ts.map +1 -1
- package/dist/infrastructure/presets/index.js +2 -0
- package/dist/infrastructure/presets/index.js.map +1 -1
- package/dist/infrastructure/presets/postgres.d.ts.map +1 -1
- package/dist/infrastructure/presets/postgres.js +9 -3
- package/dist/infrastructure/presets/postgres.js.map +1 -1
- package/dist/infrastructure/presets/redis.d.ts.map +1 -1
- package/dist/infrastructure/presets/redis.js +10 -4
- package/dist/infrastructure/presets/redis.js.map +1 -1
- package/dist/infrastructure/presets/registry.d.ts.map +1 -1
- package/dist/infrastructure/presets/registry.js +12 -0
- package/dist/infrastructure/presets/registry.js.map +1 -1
- package/dist/infrastructure/presets/tor.d.ts +74 -0
- package/dist/infrastructure/presets/tor.d.ts.map +1 -0
- package/dist/infrastructure/presets/tor.js +176 -0
- package/dist/infrastructure/presets/tor.js.map +1 -0
- package/dist/infrastructure/service-resolver.d.ts +31 -0
- package/dist/infrastructure/service-resolver.d.ts.map +1 -1
- package/dist/infrastructure/service-resolver.js +60 -9
- package/dist/infrastructure/service-resolver.js.map +1 -1
- package/dist/infrastructure/stack-infra-manager.d.ts +1 -1
- package/dist/infrastructure/stack-infra-manager.js +1 -1
- package/dist/infrastructure/types.d.ts +17 -0
- package/dist/infrastructure/types.d.ts.map +1 -1
- package/dist/observability/index.d.ts +4 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +3 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/metrics-bridge.d.ts +87 -0
- package/dist/observability/metrics-bridge.d.ts.map +1 -0
- package/dist/observability/metrics-bridge.js +295 -0
- package/dist/observability/metrics-bridge.js.map +1 -0
- package/dist/observability/metrics-server.d.ts +68 -0
- package/dist/observability/metrics-server.d.ts.map +1 -0
- package/dist/observability/metrics-server.js +146 -0
- package/dist/observability/metrics-server.js.map +1 -0
- package/dist/orchestrator/app-handle.d.ts +13 -1
- package/dist/orchestrator/app-handle.d.ts.map +1 -1
- package/dist/orchestrator/app-handle.js +12 -0
- package/dist/orchestrator/app-handle.js.map +1 -1
- package/dist/orchestrator/bootstrap-process.d.ts +49 -0
- package/dist/orchestrator/bootstrap-process.d.ts.map +1 -1
- package/dist/orchestrator/bootstrap-process.js +167 -0
- package/dist/orchestrator/bootstrap-process.js.map +1 -1
- package/dist/orchestrator/build-service.d.ts +50 -3
- package/dist/orchestrator/build-service.d.ts.map +1 -1
- package/dist/orchestrator/build-service.js +357 -85
- package/dist/orchestrator/build-service.js.map +1 -1
- package/dist/orchestrator/file-watcher.d.ts.map +1 -1
- package/dist/orchestrator/file-watcher.js +60 -38
- package/dist/orchestrator/file-watcher.js.map +1 -1
- package/dist/orchestrator/orchestrator.service.d.ts +176 -3
- package/dist/orchestrator/orchestrator.service.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.service.js +587 -56
- package/dist/orchestrator/orchestrator.service.js.map +1 -1
- package/dist/orchestrator/process-janitor.d.ts +153 -0
- package/dist/orchestrator/process-janitor.d.ts.map +1 -0
- package/dist/orchestrator/process-janitor.js +279 -0
- package/dist/orchestrator/process-janitor.js.map +1 -0
- package/dist/project/config-resolver.d.ts.map +1 -1
- package/dist/project/config-resolver.js +38 -12
- package/dist/project/config-resolver.js.map +1 -1
- package/dist/project/registry.d.ts +1 -0
- package/dist/project/registry.d.ts.map +1 -1
- package/dist/project/registry.js +30 -0
- package/dist/project/registry.js.map +1 -1
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +9 -2
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/netron-telemetry-transport.d.ts.map +1 -1
- package/dist/services/netron-telemetry-transport.js +0 -4
- package/dist/services/netron-telemetry-transport.js.map +1 -1
- package/dist/services/node-manager.service.js +1 -1
- package/dist/services/node-manager.service.js.map +1 -1
- package/dist/services/pipeline.service.d.ts +15 -0
- package/dist/services/pipeline.service.d.ts.map +1 -1
- package/dist/services/pipeline.service.js +53 -2
- package/dist/services/pipeline.service.js.map +1 -1
- package/dist/services/project.rpc-service.d.ts +4 -0
- package/dist/services/project.rpc-service.d.ts.map +1 -1
- package/dist/services/project.rpc-service.js +9 -0
- package/dist/services/project.rpc-service.js.map +1 -1
- package/dist/services/project.service.d.ts +9 -0
- package/dist/services/project.service.d.ts.map +1 -1
- package/dist/services/project.service.js +148 -21
- package/dist/services/project.service.js.map +1 -1
- package/dist/shared/dto/project.d.ts +1 -1
- package/dist/shared/dto/project.d.ts.map +1 -1
- package/dist/shared/dto/services.d.ts +24 -0
- package/dist/shared/dto/services.d.ts.map +1 -1
- package/dist/workers/health-monitor.service.d.ts.map +1 -1
- package/dist/workers/health-monitor.service.js +4 -1
- package/dist/workers/health-monitor.service.js.map +1 -1
- package/dist/workers/types.d.ts +3 -0
- package/dist/workers/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/webapp/dist/assets/Badge-D2ZBP5iK.js +1 -0
- package/webapp/dist/assets/Card-vO88g06B.js +1 -0
- package/webapp/dist/assets/CardActions-C9h7Q2wW.js +1 -0
- package/webapp/dist/assets/CardContent-BA_Xmmhk.js +1 -0
- package/webapp/dist/assets/CardHeader-ceIs__6x.js +1 -0
- package/webapp/dist/assets/Chip-Ckk0xUhK.js +1 -0
- package/webapp/dist/assets/DialogActions-BhHM7hov.js +1 -0
- package/webapp/dist/assets/DialogContentText-BT2JQRmK.js +1 -0
- package/webapp/dist/assets/FormControlLabel-CcQByVS8.js +1 -0
- package/webapp/dist/assets/Grid-14PETRQW.js +1 -0
- package/webapp/dist/assets/InputAdornment-B8aHIsQ7.js +1 -0
- package/webapp/dist/assets/Skeleton-B74-Ds6O.js +37 -0
- package/webapp/dist/assets/Switch-DvWD2wh4.js +1 -0
- package/webapp/dist/assets/SwitchBase-CDU4nY8O.js +1 -0
- package/webapp/dist/assets/Tab-DcCOYYp5.js +1 -0
- package/webapp/dist/assets/TableContainer-CzTh4mu-.js +1 -0
- package/webapp/dist/assets/TableRow-BySEreZR.js +2 -0
- package/webapp/dist/assets/TextField-BLf-BHz2.js +1 -0
- package/webapp/dist/assets/alerts-DnRbT41C.js +1 -0
- package/webapp/dist/assets/apps-CdR0VH2d.js +1 -0
- package/webapp/dist/assets/chunk-DtcAfS_W.js +1 -0
- package/webapp/dist/assets/client-DU8I_lDc.js +2 -0
- package/webapp/dist/assets/containers-Dw-rRXV1.js +1 -0
- package/webapp/dist/assets/dashboard-CzgB0uZz.js +1 -0
- package/webapp/dist/assets/dashboard-builder-DsA0yg33.js +1 -0
- package/webapp/dist/assets/deployments-DtQAoIcQ.js +1 -0
- package/webapp/dist/assets/detail-BJR-G7JG.js +1 -0
- package/webapp/dist/assets/detail-nlCeSltl.js +8 -0
- package/webapp/dist/assets/index-ByQDwp2r.js +566 -0
- package/webapp/dist/assets/isMuiElement-DwF2naaF.js +1 -0
- package/webapp/dist/assets/logs-Kn8o4H7k.js +11 -0
- package/webapp/dist/assets/metrics-DN8Zu5wT.js +2 -0
- package/webapp/dist/assets/nodes-Bgch7AtN.js +7 -0
- package/webapp/dist/assets/pipelines-Cry3qm-S.js +4 -0
- package/webapp/dist/assets/popper-BJFSj6rc.js +1 -0
- package/webapp/dist/assets/preload-helper-DSXbuxSR.js +1 -0
- package/webapp/dist/assets/projects-Dym2GI1U.js +1 -0
- package/webapp/dist/assets/react-apexcharts.esm-CSGK5Cxn.js +958 -0
- package/webapp/dist/assets/react-apexcharts.esm-DEAWOwx5.js +1003 -0
- package/webapp/dist/assets/settings-va3zTcow.js +1 -0
- package/webapp/dist/assets/sign-in-ik35umbp.js +1 -0
- package/webapp/dist/assets/stacks-CVFOnxbF.js +1 -0
- package/webapp/dist/assets/system-info-npIXAjX2.js +1 -0
- package/webapp/dist/assets/topology-bDe70vLs.js +12 -0
- package/webapp/dist/assets/traces-DIIcT8DF.js +1 -0
- package/webapp/dist/assets/use-stack-context-DHLwZv1r.js +1 -0
- package/webapp/dist/index.html +5 -6
- package/webapp/dist/assets/Chip-BySxTjyN.js +0 -1
- package/webapp/dist/assets/DialogContentText-YQWiAVrU.js +0 -1
- package/webapp/dist/assets/DialogTitle-CDkgUIwp.js +0 -1
- package/webapp/dist/assets/FormControlLabel-Bu3CCsBM.js +0 -1
- package/webapp/dist/assets/Grid-BFY8hAzL.js +0 -1
- package/webapp/dist/assets/InputAdornment-_Dayk-3y.js +0 -3
- package/webapp/dist/assets/Skeleton-BGj6Sw8m.js +0 -37
- package/webapp/dist/assets/Switch-CA6682vv.js +0 -1
- package/webapp/dist/assets/TableContainer-4epq10KJ.js +0 -1
- package/webapp/dist/assets/TextField-C73KHMVN.js +0 -1
- package/webapp/dist/assets/alerts-JEQALS7Y.js +0 -1
- package/webapp/dist/assets/apps-DMPysEKB.js +0 -1
- package/webapp/dist/assets/client-DlGPter5.js +0 -110
- package/webapp/dist/assets/containers-CvkC7do8.js +0 -1
- package/webapp/dist/assets/dashboard-C0Fre9pC.js +0 -1
- package/webapp/dist/assets/dashboard-builder-DAghqdpE.js +0 -1
- package/webapp/dist/assets/deployments-Cg1lI1uT.js +0 -1
- package/webapp/dist/assets/detail-CyFtEson.js +0 -1
- package/webapp/dist/assets/detail-DqoSGKT1.js +0 -8
- package/webapp/dist/assets/icons-ynIWYfBP.js +0 -3
- package/webapp/dist/assets/index-vKrDmEA_.js +0 -259
- package/webapp/dist/assets/isMuiElement-BRL3YuCT.js +0 -1
- package/webapp/dist/assets/logs-IovIij1C.js +0 -11
- package/webapp/dist/assets/metrics-Dufwtwjq.js +0 -1
- package/webapp/dist/assets/nodes-Brvu8W3j.js +0 -7
- package/webapp/dist/assets/pipelines-t40X4MuC.js +0 -4
- package/webapp/dist/assets/prop-types-CvVtHxp4.js +0 -1
- package/webapp/dist/assets/react-apexcharts.esm-CLO6gala.js +0 -954
- package/webapp/dist/assets/settings-DTAIST3v.js +0 -1
- package/webapp/dist/assets/sign-in-DQEAkJum.js +0 -1
- package/webapp/dist/assets/stacks-BjM1f45N.js +0 -1
- package/webapp/dist/assets/system-info-BQSbSEK-.js +0 -1
- package/webapp/dist/assets/topology-6N8-Az_Z.js +0 -12
- package/webapp/dist/assets/traces-DVXyb-4m.js +0 -1
- package/webapp/dist/assets/use-stack-context-CbE8fxBA.js +0 -1
- /package/webapp/dist/assets/{constants-DqFQJ27U.js → constants-BgnT-Xdy.js} +0 -0
- /package/webapp/dist/assets/{formatters-J3Xqn6BQ.js → formatters-Do41SRmE.js} +0 -0
- /package/webapp/dist/assets/{fs-uP8eiZAR.js → fs-CgZLtHiQ.js} +0 -0
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
-
import { PoolStrategy, SupervisionStrategy,
|
|
26
|
+
import { PoolStrategy, SupervisionStrategy, } from '@omnitron-dev/titan-pm';
|
|
27
27
|
import { EventEmitter } from '@omnitron-dev/eventemitter';
|
|
28
28
|
import { AppHandle } from './app-handle.js';
|
|
29
29
|
import { launchClassic } from './classic-launcher.js';
|
|
@@ -32,6 +32,7 @@ import { buildRestartPolicy } from '../supervisor/restart-policy.js';
|
|
|
32
32
|
import { ServiceRouter } from './service-router.js';
|
|
33
33
|
import { loadBootstrapConfig, clearCacheFor } from './bootstrap-loader.js';
|
|
34
34
|
import { BuildService } from './build-service.js';
|
|
35
|
+
import { ProcessJanitor } from './process-janitor.js';
|
|
35
36
|
import { CLI_VERSION } from '../config/defaults.js';
|
|
36
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
38
|
// Resolve bootstrap-process path — .ts in dev (tsx), .js when compiled
|
|
@@ -55,11 +56,38 @@ function mapStrategy(strategy) {
|
|
|
55
56
|
return SupervisionStrategy.ONE_FOR_ONE;
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Strip the `${project}/${stack}/` prefix from a handle key (or
|
|
61
|
+
* leave a bare name untouched). Used to identify "the same physical
|
|
62
|
+
* app under any registration form" so duplicate registrations from
|
|
63
|
+
* stack mode + bare-name RPC paths can be deduplicated.
|
|
64
|
+
*
|
|
65
|
+
* Examples:
|
|
66
|
+
* effectiveAppName('omni/dev/paysys') === 'paysys'
|
|
67
|
+
* effectiveAppName('paysys') === 'paysys'
|
|
68
|
+
* effectiveAppName('a/b/c/paysys') === 'paysys' (last segment)
|
|
69
|
+
*/
|
|
70
|
+
function effectiveAppName(key) {
|
|
71
|
+
const idx = key.lastIndexOf('/');
|
|
72
|
+
return idx >= 0 ? key.slice(idx + 1) : key;
|
|
73
|
+
}
|
|
74
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
75
|
export class OrchestratorService extends EventEmitter {
|
|
59
76
|
logger;
|
|
60
77
|
pm;
|
|
61
78
|
stateStore;
|
|
62
79
|
handles = new Map();
|
|
80
|
+
/**
|
|
81
|
+
* Per-name in-flight start lock (T#57). When two `startApp()` calls
|
|
82
|
+
* arrive concurrently for the same app, the first one's promise is
|
|
83
|
+
* stored here; the second one awaits and returns the same handle
|
|
84
|
+
* rather than racing through to a parallel `launchBootstrapMode` /
|
|
85
|
+
* `launchClassicMode` that would spawn a second worker pool and
|
|
86
|
+
* leak the original. The map is cleared in a `finally` once the
|
|
87
|
+
* launch settles (success or failure) so the next legitimate call
|
|
88
|
+
* for the same name isn't permanently locked out.
|
|
89
|
+
*/
|
|
90
|
+
startingApps = new Map();
|
|
63
91
|
config = null;
|
|
64
92
|
cwd;
|
|
65
93
|
metricsTimer = null;
|
|
@@ -70,16 +98,71 @@ export class OrchestratorService extends EventEmitter {
|
|
|
70
98
|
buildService = null;
|
|
71
99
|
/** Cached build results per app name */
|
|
72
100
|
buildResults = new Map();
|
|
101
|
+
/**
|
|
102
|
+
* Per-app restart coalescer state.
|
|
103
|
+
*
|
|
104
|
+
* `inFlight` — the currently-running restart promise (so a concurrent
|
|
105
|
+
* trigger doesn't kick off a parallel restart and tear down the half-
|
|
106
|
+
* started state from the previous one).
|
|
107
|
+
* `triggerSerial` — monotonically incremented by external trigger
|
|
108
|
+
* entry points (BuildService onRebuild, RPC restartApp, manual
|
|
109
|
+
* `omnitron restart`, deploy.service). Internal coalescer arms
|
|
110
|
+
* that queue themselves DO NOT bump it.
|
|
111
|
+
* `firedSerial` — value of `triggerSerial` that the most recently
|
|
112
|
+
* completed restart consumed. A trailing coalesced restart only
|
|
113
|
+
* fires when `triggerSerial > firedSerial`, i.e. a genuinely new
|
|
114
|
+
* external trigger arrived during the in-flight window. Two
|
|
115
|
+
* coalescer arms colliding can no longer cause a self-perpetuating
|
|
116
|
+
* loop because neither bumps `triggerSerial`.
|
|
117
|
+
* `cooldownUntil` — absolute epoch ms timestamp. Any trigger received
|
|
118
|
+
* before this clears is queued instead of fired immediately.
|
|
119
|
+
* Prevents the storm where esbuild's onEnd fires for a freshly-
|
|
120
|
+
* spawned context milliseconds after the app reports online.
|
|
121
|
+
*/
|
|
122
|
+
restartCoalescer = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* Wall-clock window in ms during which restart triggers from
|
|
125
|
+
* BuildService.watchApp are deferred after `app:online`. Sized so a
|
|
126
|
+
* normal bundle build + child spawn + DI bootstrap fits comfortably
|
|
127
|
+
* inside; anything legitimately changing more often than this is a
|
|
128
|
+
* developer typing very fast, which the regular debounce handles.
|
|
129
|
+
*/
|
|
130
|
+
static POST_ONLINE_COOLDOWN_MS = 3_000;
|
|
131
|
+
/**
|
|
132
|
+
* Orphan-process reaper. Lazily created on first `startAll` so unit
|
|
133
|
+
* tests that hit `startApp` directly don't get a periodic timer.
|
|
134
|
+
*/
|
|
135
|
+
janitor = null;
|
|
73
136
|
/** Daemon's Netron instance for native service routing */
|
|
74
137
|
daemonNetron = null;
|
|
75
138
|
/** Daemon Unix socket path for child-to-daemon Netron connections */
|
|
76
139
|
daemonSocketPath = null;
|
|
140
|
+
/**
|
|
141
|
+
* Promise that resolves once the daemon's Netron is wired up. Apps that
|
|
142
|
+
* declare topology cannot launch until this is ready — instead of failing
|
|
143
|
+
* with "daemon Netron not available" the launcher awaits this promise
|
|
144
|
+
* (with a generous timeout) so transient startup-order interleavings
|
|
145
|
+
* resolve themselves instead of becoming user-visible errors.
|
|
146
|
+
*/
|
|
147
|
+
daemonNetronReady;
|
|
148
|
+
resolveDaemonNetronReady = () => undefined;
|
|
149
|
+
/**
|
|
150
|
+
* Optional metrics sink. Set by the daemon at boot via
|
|
151
|
+
* `setMetricsBridge` so the orchestrator can forward janitor sweeps,
|
|
152
|
+
* esbuild rebuilds, and lifecycle events without coupling itself to
|
|
153
|
+
* the metrics module. Null means metrics are disabled (tests, slim
|
|
154
|
+
* bootstraps).
|
|
155
|
+
*/
|
|
156
|
+
metricsBridge = null;
|
|
77
157
|
constructor(logger, pm, stateStore, cwd) {
|
|
78
158
|
super();
|
|
79
159
|
this.logger = logger;
|
|
80
160
|
this.pm = pm;
|
|
81
161
|
this.stateStore = stateStore;
|
|
82
162
|
this.cwd = cwd ?? process.cwd();
|
|
163
|
+
this.daemonNetronReady = new Promise((resolve) => {
|
|
164
|
+
this.resolveDaemonNetronReady = resolve;
|
|
165
|
+
});
|
|
83
166
|
}
|
|
84
167
|
/**
|
|
85
168
|
* Set the daemon Netron instance and socket path for native topology routing.
|
|
@@ -88,28 +171,155 @@ export class OrchestratorService extends EventEmitter {
|
|
|
88
171
|
setDaemonNetron(netron, socketPath) {
|
|
89
172
|
this.daemonNetron = netron;
|
|
90
173
|
this.daemonSocketPath = socketPath;
|
|
174
|
+
this.resolveDaemonNetronReady();
|
|
91
175
|
this.logger.info({ socketPath }, 'Daemon Netron set for native topology routing');
|
|
92
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Wire the metrics bridge. Called once during daemon bootstrap.
|
|
179
|
+
* After this, janitor sweeps, esbuild rebuilds, and orchestrator
|
|
180
|
+
* lifecycle events are forwarded to Prometheus.
|
|
181
|
+
*/
|
|
182
|
+
setMetricsBridge(bridge) {
|
|
183
|
+
this.metricsBridge = bridge;
|
|
184
|
+
bridge.attachOrchestrator(this);
|
|
185
|
+
this.wireOrchestratorTelemetry();
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Subscribe to our own EventEmitter to forward state-change events to
|
|
189
|
+
* the metrics bridge. Idempotent so repeated `setMetricsBridge` calls
|
|
190
|
+
* (mostly tests) don't multiply listeners.
|
|
191
|
+
*/
|
|
192
|
+
telemetryWired = false;
|
|
193
|
+
wireOrchestratorTelemetry() {
|
|
194
|
+
if (this.telemetryWired)
|
|
195
|
+
return;
|
|
196
|
+
this.telemetryWired = true;
|
|
197
|
+
this.on('app:restart', (app, attempt) => {
|
|
198
|
+
this.metricsBridge?.recordAppRestart(app, attempt, 'crash');
|
|
199
|
+
});
|
|
200
|
+
this.on('app:crash', (app) => {
|
|
201
|
+
this.metricsBridge?.recordAppCrash(app);
|
|
202
|
+
});
|
|
203
|
+
this.on('app:escalated', (app) => {
|
|
204
|
+
this.metricsBridge?.recordAppEscalated(app);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Wait until the daemon Netron is available, with a hard ceiling so we
|
|
209
|
+
* never hang forever if `setDaemonNetron()` was never wired in.
|
|
210
|
+
*/
|
|
211
|
+
async ensureDaemonNetronReady(timeoutMs = 30_000) {
|
|
212
|
+
if (this.daemonNetron && this.daemonSocketPath)
|
|
213
|
+
return;
|
|
214
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
215
|
+
const timer = setTimeout(() => reject(new Error(`Daemon Netron not initialized within ${timeoutMs}ms — wiring bug?`)), timeoutMs);
|
|
216
|
+
timer.unref?.();
|
|
217
|
+
});
|
|
218
|
+
await Promise.race([this.daemonNetronReady, timeout]);
|
|
219
|
+
}
|
|
93
220
|
// ============================================================================
|
|
94
221
|
// Lifecycle
|
|
95
222
|
// ============================================================================
|
|
96
223
|
async startAll(config) {
|
|
97
224
|
this.config = config;
|
|
225
|
+
// Reap any fork-workers left over from a previous daemon BEFORE
|
|
226
|
+
// launching apps. Without this, a hard-killed daemon's children
|
|
227
|
+
// are still alive (now adopted by init); they hold DB connections
|
|
228
|
+
// and sockets that the new apps will fight for.
|
|
229
|
+
await this.ensureJanitor().coldStartSweep();
|
|
230
|
+
// Dependency-aware skip: if app A fails to start, every app that
|
|
231
|
+
// declares A in `dependsOn` (transitively) is skipped instead of
|
|
232
|
+
// attempted. Without this, a downstream app would launch, fail to
|
|
233
|
+
// reach its missing dependency, crash, and chew through its restart
|
|
234
|
+
// budget — masking the real root cause.
|
|
235
|
+
const failed = new Set();
|
|
236
|
+
const blocked = new Set();
|
|
98
237
|
for (const batch of resolveStartupOrder(config.apps)) {
|
|
99
238
|
const enabled = batch.filter((e) => e.enabled !== false);
|
|
100
239
|
await Promise.all(enabled.map(async (e) => {
|
|
240
|
+
const blockingDep = (e.dependsOn ?? []).find((dep) => failed.has(dep) || blocked.has(dep));
|
|
241
|
+
if (blockingDep) {
|
|
242
|
+
blocked.add(e.name);
|
|
243
|
+
this.logger.warn({ app: e.name, blockedBy: blockingDep }, 'Skipping app startup — depends on a failed/blocked app');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
101
246
|
try {
|
|
102
247
|
await this.startApp(e, config);
|
|
103
248
|
}
|
|
104
249
|
catch (err) {
|
|
250
|
+
failed.add(e.name);
|
|
105
251
|
this.logger.error({ app: e.name, error: err.message }, 'Failed to start app — continuing with remaining apps');
|
|
106
252
|
}
|
|
107
253
|
}));
|
|
108
254
|
}
|
|
255
|
+
if (failed.size > 0 || blocked.size > 0) {
|
|
256
|
+
this.logger.warn({ failed: [...failed], blocked: [...blocked] }, 'Startup completed with failures — some apps did not launch');
|
|
257
|
+
}
|
|
258
|
+
// Periodic sweep handles runtime leaks from partially-failed
|
|
259
|
+
// restart cycles (esbuild-rebuild → spawn-fail → orphan).
|
|
260
|
+
this.ensureJanitor().start();
|
|
109
261
|
this.startMetricsPolling(config.monitoring.metrics.interval);
|
|
110
262
|
this.persistState();
|
|
111
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Lazily create the orphan-process janitor with the live "owned
|
|
266
|
+
* pids" snapshot wired to the orchestrator's handle map.
|
|
267
|
+
*/
|
|
268
|
+
ensureJanitor() {
|
|
269
|
+
if (this.janitor)
|
|
270
|
+
return this.janitor;
|
|
271
|
+
this.janitor = new ProcessJanitor({
|
|
272
|
+
logger: this.logger,
|
|
273
|
+
getOwnedPids: () => this.collectOwnedPids(),
|
|
274
|
+
onMetrics: (m) => this.metricsBridge?.recordJanitorSweep(m),
|
|
275
|
+
});
|
|
276
|
+
return this.janitor;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Walk every live handle's supervisor children and collect the OS
|
|
280
|
+
* pids the orchestrator currently considers ours. Cheap — called
|
|
281
|
+
* every janitor sweep.
|
|
282
|
+
*/
|
|
283
|
+
collectOwnedPids() {
|
|
284
|
+
const owned = new Set();
|
|
285
|
+
for (const handle of this.handles.values()) {
|
|
286
|
+
const supervisor = handle.supervisor;
|
|
287
|
+
if (!supervisor)
|
|
288
|
+
continue;
|
|
289
|
+
for (const childName of supervisor.getChildNames()) {
|
|
290
|
+
const processId = supervisor.getChildProcessId(childName);
|
|
291
|
+
if (!processId)
|
|
292
|
+
continue;
|
|
293
|
+
const workerHandle = this.pm.getWorkerHandle(processId);
|
|
294
|
+
const pid = workerHandle?.pid;
|
|
295
|
+
if (typeof pid === 'number')
|
|
296
|
+
owned.add(pid);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return owned;
|
|
300
|
+
}
|
|
112
301
|
async startApp(entry, config) {
|
|
302
|
+
// T#57: coalesce concurrent calls for the same app name. Without
|
|
303
|
+
// this, two near-simultaneous `startApp({ name: 'main' })` calls
|
|
304
|
+
// both see `this.handles.get('main')` as undefined-or-not-online
|
|
305
|
+
// (the first one's handle is in status='starting'), both fall
|
|
306
|
+
// through to `launchBootstrapMode`, and we spawn two parallel
|
|
307
|
+
// worker pools competing for the same port. Promise coalescing
|
|
308
|
+
// — return the in-flight promise to the second caller — gives
|
|
309
|
+
// both callers the same handle and ensures only one launch
|
|
310
|
+
// actually runs.
|
|
311
|
+
const inflight = this.startingApps.get(entry.name);
|
|
312
|
+
if (inflight) {
|
|
313
|
+
this.logger.debug({ app: entry.name }, 'Coalescing concurrent startApp into in-flight launch (T#57)');
|
|
314
|
+
return inflight;
|
|
315
|
+
}
|
|
316
|
+
const promise = this.startAppInternal(entry, config).finally(() => {
|
|
317
|
+
this.startingApps.delete(entry.name);
|
|
318
|
+
});
|
|
319
|
+
this.startingApps.set(entry.name, promise);
|
|
320
|
+
return promise;
|
|
321
|
+
}
|
|
322
|
+
async startAppInternal(entry, config) {
|
|
113
323
|
// Persist ecosystem config for restartApp — stack mode passes config per-app,
|
|
114
324
|
// but restartApp calls startApp without it.
|
|
115
325
|
if (config && !this.config) {
|
|
@@ -118,6 +328,41 @@ export class OrchestratorService extends EventEmitter {
|
|
|
118
328
|
const cfg = config ?? this.config;
|
|
119
329
|
if (!cfg)
|
|
120
330
|
throw new Error('Ecosystem config not loaded');
|
|
331
|
+
// Deduplicate by effective app identity. Project-mode registers
|
|
332
|
+
// apps as `${project}/${stack}/${app}`; non-project paths use the
|
|
333
|
+
// bare `${app}` short name. They refer to the SAME physical app
|
|
334
|
+
// and must never coexist as separate handles — the duplicate
|
|
335
|
+
// would compete for the port and the orphan-reaper's owned-pid
|
|
336
|
+
// accounting would split between them.
|
|
337
|
+
//
|
|
338
|
+
// Policy:
|
|
339
|
+
// - exact key already exists, online → return it (idempotent)
|
|
340
|
+
// - exact key already exists, not online → fall through and re-launch
|
|
341
|
+
// under the same key
|
|
342
|
+
// - DIFFERENT key with the same effective short name → reject:
|
|
343
|
+
// operator should restart by canonical name, not register an
|
|
344
|
+
// alias.
|
|
345
|
+
const effective = effectiveAppName(entry.name);
|
|
346
|
+
for (const [key, h] of this.handles) {
|
|
347
|
+
if (key === entry.name)
|
|
348
|
+
continue;
|
|
349
|
+
if (effectiveAppName(key) !== effective)
|
|
350
|
+
continue;
|
|
351
|
+
// Same effective app under a different key.
|
|
352
|
+
if (h.status === 'online') {
|
|
353
|
+
this.logger.warn({ existing: key, attempted: entry.name, effective }, 'App already running under a canonical name — refusing duplicate registration');
|
|
354
|
+
return h;
|
|
355
|
+
}
|
|
356
|
+
// Existing handle is errored/stopped — replace silently so a
|
|
357
|
+
// failed previous launch doesn't permanently block the canonical
|
|
358
|
+
// form.
|
|
359
|
+
this.logger.info({ stale: key, replacing_with: entry.name, effective }, 'Replacing stale duplicate handle');
|
|
360
|
+
this.handles.delete(key);
|
|
361
|
+
// Drop any metrics that were collected under the stale alias so it
|
|
362
|
+
// doesn't haunt future snapshots as a ghost offline app.
|
|
363
|
+
if (key !== entry.name)
|
|
364
|
+
this.metricsBridge?.evictApp(key);
|
|
365
|
+
}
|
|
121
366
|
const existing = this.handles.get(entry.name);
|
|
122
367
|
if (existing && existing.status === 'online') {
|
|
123
368
|
this.logger.warn({ app: entry.name }, 'App already running');
|
|
@@ -145,7 +390,8 @@ export class OrchestratorService extends EventEmitter {
|
|
|
145
390
|
}
|
|
146
391
|
}
|
|
147
392
|
async stopApp(name, force = false, timeout = 10_000) {
|
|
148
|
-
const
|
|
393
|
+
const canonical = this.resolveAppName(name) ?? name;
|
|
394
|
+
const handle = this.handles.get(canonical);
|
|
149
395
|
if (!handle || handle.status === 'stopped')
|
|
150
396
|
return;
|
|
151
397
|
handle.markStopping();
|
|
@@ -178,6 +424,11 @@ export class OrchestratorService extends EventEmitter {
|
|
|
178
424
|
clearInterval(this.metricsTimer);
|
|
179
425
|
this.metricsTimer = null;
|
|
180
426
|
}
|
|
427
|
+
// Stop the janitor first so it doesn't race the controlled
|
|
428
|
+
// shutdown by trying to "reap" children that are mid-stop.
|
|
429
|
+
if (this.janitor) {
|
|
430
|
+
this.janitor.stop();
|
|
431
|
+
}
|
|
181
432
|
let count = 0;
|
|
182
433
|
// When force=true (daemon shutdown), use short timeout per app (3s SIGTERM, then SIGKILL)
|
|
183
434
|
const perAppTimeout = force ? 3_000 : 10_000;
|
|
@@ -209,36 +460,171 @@ export class OrchestratorService extends EventEmitter {
|
|
|
209
460
|
this.buildResults.clear();
|
|
210
461
|
return count;
|
|
211
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Restart an app, coalescing concurrent triggers.
|
|
465
|
+
*
|
|
466
|
+
* The dev-mode hot-reload path can deliver many restart triggers per
|
|
467
|
+
* second (file save → esbuild rebuild → onEnd → debounced → restart),
|
|
468
|
+
* and naive parallelism here ends in cross-stomping state mutations
|
|
469
|
+
* (one restart's stopApp tearing down the other's freshly-spawned
|
|
470
|
+
* child). This wrapper serialises restarts per app:
|
|
471
|
+
*
|
|
472
|
+
* - If a restart is already in flight, we set the `pending` bit and
|
|
473
|
+
* await the current restart. When it completes we run exactly one
|
|
474
|
+
* follow-up — extra triggers received in the meantime collapse onto
|
|
475
|
+
* the same bit, so a burst of N triggers produces at most 2 actual
|
|
476
|
+
* restarts (the current one + one trailing).
|
|
477
|
+
* - If we're inside the post-online cooldown window, the trigger is
|
|
478
|
+
* queued (via `pending`) and fires after the cooldown clears.
|
|
479
|
+
*
|
|
480
|
+
* The hot path is `restartAppNow()` which contains the actual work and
|
|
481
|
+
* is what tests / non-coalescing callers can hit directly.
|
|
482
|
+
*/
|
|
212
483
|
async restartApp(name) {
|
|
484
|
+
// External entry — record one new trigger and dispatch.
|
|
485
|
+
return this.restartAppCoalesced(name, /* fromExternal */ true);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Internal coalescer dispatcher.
|
|
489
|
+
*
|
|
490
|
+
* `fromExternal=true` bumps `triggerSerial` (a user / file-watcher /
|
|
491
|
+
* RPC asked us to restart). `fromExternal=false` is used by the
|
|
492
|
+
* recursive arms (cooldown-wait, await-inflight) which are NOT new
|
|
493
|
+
* triggers, just continuations — they must not bump or the trailing
|
|
494
|
+
* coalesced restart would re-fire forever.
|
|
495
|
+
*/
|
|
496
|
+
async restartAppCoalesced(name, fromExternal) {
|
|
497
|
+
const canonical = this.resolveAppName(name) ?? name;
|
|
498
|
+
const handle = this.handles.get(canonical);
|
|
499
|
+
if (!handle)
|
|
500
|
+
throw new Error(`Unknown app: ${name}`);
|
|
501
|
+
name = canonical;
|
|
502
|
+
const state = this.getCoalescerState(name);
|
|
503
|
+
if (fromExternal)
|
|
504
|
+
state.triggerSerial += 1;
|
|
505
|
+
const observedSerial = state.triggerSerial;
|
|
506
|
+
// Cooldown: the app reported online recently. Wait it out, then
|
|
507
|
+
// proceed as a non-external continuation (we already counted this
|
|
508
|
+
// trigger when we first arrived).
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
if (now < state.cooldownUntil) {
|
|
511
|
+
const waitMs = state.cooldownUntil - now;
|
|
512
|
+
this.logger.debug({ app: name, cooldownRemainingMs: waitMs }, 'Restart trigger received during post-online cooldown — queued');
|
|
513
|
+
await sleep(waitMs);
|
|
514
|
+
return this.restartAppCoalesced(name, /* fromExternal */ false);
|
|
515
|
+
}
|
|
516
|
+
// Already restarting — wait for it, then re-enter as a continuation.
|
|
517
|
+
// The trailing-fire gate at the end of the in-flight finally block
|
|
518
|
+
// is what re-fires us if our serial outranks what got consumed.
|
|
519
|
+
if (state.inFlight) {
|
|
520
|
+
await state.inFlight.catch(() => { });
|
|
521
|
+
// If the in-flight restart already consumed our serial (or a
|
|
522
|
+
// later one), we have nothing new to do — return its result by
|
|
523
|
+
// re-reading the current handle. Otherwise fall through and
|
|
524
|
+
// claim the slot ourselves.
|
|
525
|
+
if (state.firedSerial >= observedSerial) {
|
|
526
|
+
const refreshed = this.handles.get(name);
|
|
527
|
+
if (refreshed)
|
|
528
|
+
return refreshed;
|
|
529
|
+
}
|
|
530
|
+
return this.restartAppCoalesced(name, /* fromExternal */ false);
|
|
531
|
+
}
|
|
532
|
+
// Take the slot. Capture which serial this run is consuming —
|
|
533
|
+
// anything bumped after this is the next run's problem.
|
|
534
|
+
const consumedSerial = state.triggerSerial;
|
|
535
|
+
const promise = this.restartAppNow(name);
|
|
536
|
+
state.inFlight = promise;
|
|
537
|
+
try {
|
|
538
|
+
// Cooldown window is started from the `app:online` transition
|
|
539
|
+
// inside startApp(), not here, so the timer is tied to the moment
|
|
540
|
+
// the new child reports ready, not the moment our promise resolves.
|
|
541
|
+
return await promise;
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
state.inFlight = null;
|
|
545
|
+
state.firedSerial = consumedSerial;
|
|
546
|
+
// Trailing coalesced fire — only if a GENUINELY NEW external
|
|
547
|
+
// trigger arrived between when we claimed the slot and now.
|
|
548
|
+
// Two coalescer arms colliding can no longer cause this branch
|
|
549
|
+
// to fire because they don't bump triggerSerial.
|
|
550
|
+
if (state.triggerSerial > consumedSerial) {
|
|
551
|
+
this.restartAppCoalesced(name, /* fromExternal */ false).catch((err) => {
|
|
552
|
+
this.logger.error({ app: name, error: err.message }, 'Trailing coalesced restart failed');
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Internal restart hot path — assumes coalescer has cleared us to proceed.
|
|
559
|
+
*
|
|
560
|
+
* Architecture note (Fix #247): we deliberately do NOT tear down the
|
|
561
|
+
* esbuild watch or invalidate buildResults here. The watch is what
|
|
562
|
+
* triggered us in the first place — its bundle is already fresh, and
|
|
563
|
+
* disposing the context creates a window where the next save can be
|
|
564
|
+
* missed (esbuild's metafile doesn't carry over between contexts).
|
|
565
|
+
*
|
|
566
|
+
* `launchBootstrapMode` sees the live buildResult + watch and skips
|
|
567
|
+
* the cold rebuild, so a restart cycle is just: kill old child →
|
|
568
|
+
* spawn new child with the bundle that was already on disk. The
|
|
569
|
+
* bootstrap-config cache is cleared because changes to `bootstrap.ts`
|
|
570
|
+
* (topology definition) need to take effect on the next start.
|
|
571
|
+
*/
|
|
572
|
+
async restartAppNow(name) {
|
|
213
573
|
const handle = this.handles.get(name);
|
|
214
574
|
if (!handle)
|
|
215
575
|
throw new Error(`Unknown app: ${name}`);
|
|
216
576
|
const entry = handle.entry;
|
|
217
|
-
// In dev mode, clear bootstrap config cache so topology changes are picked up
|
|
218
577
|
if (this.devMode && entry.bootstrap) {
|
|
219
|
-
clearCacheFor(path.resolve(this.cwd, entry.bootstrap));
|
|
220
|
-
// Stop esbuild watch and clear stale build result — launchBootstrapMode will rebuild + re-watch
|
|
221
|
-
if (this.buildService) {
|
|
222
|
-
await this.buildService.unwatchApp(name);
|
|
223
|
-
}
|
|
224
|
-
this.buildResults.delete(name);
|
|
578
|
+
clearCacheFor(path.resolve(entry.cwd ?? this.cwd, entry.bootstrap));
|
|
225
579
|
}
|
|
226
580
|
await this.stopApp(name);
|
|
227
581
|
return this.startApp(entry);
|
|
228
582
|
}
|
|
583
|
+
getCoalescerState(name) {
|
|
584
|
+
let state = this.restartCoalescer.get(name);
|
|
585
|
+
if (!state) {
|
|
586
|
+
state = { inFlight: null, triggerSerial: 0, firedSerial: 0, cooldownUntil: 0 };
|
|
587
|
+
this.restartCoalescer.set(name, state);
|
|
588
|
+
}
|
|
589
|
+
return state;
|
|
590
|
+
}
|
|
229
591
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
592
|
+
* Start the post-online cooldown for an app. Called from the `app:online`
|
|
593
|
+
* transition (both initial startApp and subsequent restartApp paths
|
|
594
|
+
* funnel through this), so a freshly-online app cannot be torn down by
|
|
595
|
+
* a rebuild signal that was already in flight when it finished booting.
|
|
596
|
+
*/
|
|
597
|
+
startPostOnlineCooldown(name) {
|
|
598
|
+
const state = this.getCoalescerState(name);
|
|
599
|
+
state.cooldownUntil = Date.now() + OrchestratorService.POST_ONLINE_COOLDOWN_MS;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Resolve a user-supplied name to a canonical handle key.
|
|
603
|
+
*
|
|
604
|
+
* Inputs accepted:
|
|
605
|
+
* - exact canonical key (`omni/dev/paysys`) → returned as-is
|
|
606
|
+
* - bare short name (`paysys`) → returns the canonical key whose
|
|
607
|
+
* last `/`-segment matches, IF AND ONLY IF the match is unique.
|
|
608
|
+
*
|
|
609
|
+
* Returns `undefined` for no match. Throws on ambiguity (multiple
|
|
610
|
+
* stacks expose the same short name) so the caller can surface a
|
|
611
|
+
* clear error to the operator instead of operating on the wrong
|
|
612
|
+
* app.
|
|
233
613
|
*/
|
|
234
614
|
resolveAppName(rawName) {
|
|
235
615
|
if (this.handles.has(rawName))
|
|
236
616
|
return rawName;
|
|
617
|
+
const effective = effectiveAppName(rawName);
|
|
618
|
+
const matches = [];
|
|
237
619
|
for (const key of this.handles.keys()) {
|
|
238
|
-
if (key
|
|
239
|
-
|
|
620
|
+
if (effectiveAppName(key) === effective)
|
|
621
|
+
matches.push(key);
|
|
240
622
|
}
|
|
241
|
-
|
|
623
|
+
if (matches.length === 0)
|
|
624
|
+
return undefined;
|
|
625
|
+
if (matches.length === 1)
|
|
626
|
+
return matches[0];
|
|
627
|
+
throw new Error(`App name '${rawName}' is ambiguous — multiple matches: ${matches.join(', ')}. Use the full canonical name.`);
|
|
242
628
|
}
|
|
243
629
|
/**
|
|
244
630
|
* Reload app with zero-downtime strategy:
|
|
@@ -251,9 +637,11 @@ export class OrchestratorService extends EventEmitter {
|
|
|
251
637
|
* zero-downtime is not possible (single-process, no supervisor).
|
|
252
638
|
*/
|
|
253
639
|
async reloadApp(name) {
|
|
254
|
-
const
|
|
640
|
+
const canonical = this.resolveAppName(name) ?? name;
|
|
641
|
+
const handle = this.handles.get(canonical);
|
|
255
642
|
if (!handle)
|
|
256
643
|
throw new Error(`Unknown app: ${name}`);
|
|
644
|
+
name = canonical;
|
|
257
645
|
// Classic mode or no supervisor — cannot do zero-downtime, fall back to restart
|
|
258
646
|
if (handle.mode !== 'bootstrap' || !handle.supervisor) {
|
|
259
647
|
return this.restartApp(name);
|
|
@@ -313,9 +701,11 @@ export class OrchestratorService extends EventEmitter {
|
|
|
313
701
|
// Scaling — delegates to PM supervisor
|
|
314
702
|
// ============================================================================
|
|
315
703
|
async scaleApp(name, instances) {
|
|
316
|
-
const
|
|
704
|
+
const canonical = this.resolveAppName(name) ?? name;
|
|
705
|
+
const handle = this.handles.get(canonical);
|
|
317
706
|
if (!handle)
|
|
318
707
|
throw new Error(`Unknown app: ${name}`);
|
|
708
|
+
name = canonical;
|
|
319
709
|
if (!this.config)
|
|
320
710
|
throw new Error('Ecosystem config not loaded');
|
|
321
711
|
if (handle.mode !== 'bootstrap') {
|
|
@@ -345,11 +735,18 @@ export class OrchestratorService extends EventEmitter {
|
|
|
345
735
|
return handles.map((h) => this.toProcessInfo(h));
|
|
346
736
|
}
|
|
347
737
|
getApp(name) {
|
|
348
|
-
const
|
|
738
|
+
const canonical = this.resolveAppName(name) ?? name;
|
|
739
|
+
const h = this.handles.get(canonical);
|
|
349
740
|
return h ? this.toProcessInfo(h) : null;
|
|
350
741
|
}
|
|
351
742
|
getHandle(name) {
|
|
352
|
-
|
|
743
|
+
// Match getAppStatus / getLogs name-resolution semantics — accept either
|
|
744
|
+
// a fully namespaced handle key ('project/stack/app') or the bare app
|
|
745
|
+
// name and resolve to the right handle. Without this, CLI commands like
|
|
746
|
+
// `omnitron env main` fail with "Unknown app: main" even though the
|
|
747
|
+
// app exists as `omni/dev/main`.
|
|
748
|
+
const resolved = this.resolveAppName(name);
|
|
749
|
+
return this.handles.get(resolved ?? name);
|
|
353
750
|
}
|
|
354
751
|
/**
|
|
355
752
|
* List all handle names matching a prefix.
|
|
@@ -367,6 +764,35 @@ export class OrchestratorService extends EventEmitter {
|
|
|
367
764
|
// ============================================================================
|
|
368
765
|
// Monitoring — async, delegates to PM supervisor for bootstrap mode
|
|
369
766
|
// ============================================================================
|
|
767
|
+
/**
|
|
768
|
+
* Pull the live DI dependency graph from a running app's first child
|
|
769
|
+
* worker. Used by the daemon RPC backing `omnitron inspect <app>
|
|
770
|
+
* --graph`. Returns null if the app isn't running, has no children,
|
|
771
|
+
* or the worker doesn't expose `getDependencyGraph` (older bootstraps).
|
|
772
|
+
*/
|
|
773
|
+
async getDependencyGraph(name) {
|
|
774
|
+
const handle = this.getHandle(name);
|
|
775
|
+
if (!handle || handle.mode !== 'bootstrap' || !handle.supervisor)
|
|
776
|
+
return null;
|
|
777
|
+
const childNames = handle.supervisor.getChildNames();
|
|
778
|
+
for (const childName of childNames) {
|
|
779
|
+
const processId = handle.supervisor.getChildProcessId(childName);
|
|
780
|
+
if (!processId)
|
|
781
|
+
continue;
|
|
782
|
+
const workerHandle = this.pm.getWorkerHandle(processId);
|
|
783
|
+
const proxy = workerHandle?.proxy;
|
|
784
|
+
if (!proxy?.getDependencyGraph)
|
|
785
|
+
continue;
|
|
786
|
+
try {
|
|
787
|
+
const graph = await proxy.getDependencyGraph();
|
|
788
|
+
return graph;
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
this.logger.warn({ app: name, child: childName, error: err.message }, 'getDependencyGraph: child threw — trying next worker');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
370
796
|
async getMetrics(name) {
|
|
371
797
|
const result = {};
|
|
372
798
|
for (const [appName, handle] of this.handles) {
|
|
@@ -488,8 +914,13 @@ export class OrchestratorService extends EventEmitter {
|
|
|
488
914
|
}
|
|
489
915
|
getLogs(name, lines = 100) {
|
|
490
916
|
const result = [];
|
|
917
|
+
// Stack-mode handles are keyed as `<project>/<stack>/<app>` while CLI
|
|
918
|
+
// users typically pass just the bare app name. Resolve via the same
|
|
919
|
+
// matcher used by getAppStatus() so `omnitron logs main` works in
|
|
920
|
+
// both single-app and stack modes.
|
|
921
|
+
const resolved = name ? this.resolveAppName(name) ?? name : undefined;
|
|
491
922
|
for (const [appName, handle] of this.handles) {
|
|
492
|
-
if (
|
|
923
|
+
if (resolved && appName !== resolved)
|
|
493
924
|
continue;
|
|
494
925
|
result.push({ app: appName, lines: handle.getLogs(lines) });
|
|
495
926
|
}
|
|
@@ -535,7 +966,7 @@ export class OrchestratorService extends EventEmitter {
|
|
|
535
966
|
*/
|
|
536
967
|
async launchBootstrapMode(entry, handle, config) {
|
|
537
968
|
handle.markStarting();
|
|
538
|
-
const bootstrapAbsPath = path.resolve(this.cwd, entry.bootstrap);
|
|
969
|
+
const bootstrapAbsPath = path.resolve(entry.cwd ?? this.cwd, entry.bootstrap);
|
|
539
970
|
// Load bootstrap definition to check for process topology and requirements
|
|
540
971
|
let topology;
|
|
541
972
|
let definition;
|
|
@@ -588,25 +1019,92 @@ export class OrchestratorService extends EventEmitter {
|
|
|
588
1019
|
}
|
|
589
1020
|
}
|
|
590
1021
|
// Build all entry points via esbuild (dev mode bundles from source .ts)
|
|
1022
|
+
//
|
|
1023
|
+
// On a *restart* (as opposed to a first start), buildResults already
|
|
1024
|
+
// has a fresh entry — esbuild's watcher just produced the bundle
|
|
1025
|
+
// that triggered us. Reusing it skips a redundant cold rebuild
|
|
1026
|
+
// (which was costing ~500ms per restart and racing against the
|
|
1027
|
+
// watcher's own incremental output). The watch context is also
|
|
1028
|
+
// preserved (we never unwatched), so we don't re-arm it either.
|
|
591
1029
|
if (definition && this.devMode) {
|
|
592
1030
|
try {
|
|
593
1031
|
if (!this.buildService) {
|
|
594
|
-
this.buildService = new BuildService(/* isDev */ true
|
|
1032
|
+
this.buildService = new BuildService(/* isDev */ true, {
|
|
1033
|
+
onEsbuildReset: (info) => this.metricsBridge?.recordResilientHandleReset(info),
|
|
1034
|
+
onBuildResult: ({ app, kind, durationMs, ok }) => this.metricsBridge?.recordEsbuildBuild(app, kind, durationMs, ok),
|
|
1035
|
+
});
|
|
595
1036
|
}
|
|
596
|
-
const
|
|
597
|
-
this.
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1037
|
+
const existing = this.buildResults.get(entry.name);
|
|
1038
|
+
const existingWatch = this.buildService.isWatching?.(entry.name);
|
|
1039
|
+
// Bundle on-disk presence check. A restart can race against
|
|
1040
|
+
// the user (or a script) removing `.omnitron-build/` between
|
|
1041
|
+
// builds — without this stat the orchestrator hands the
|
|
1042
|
+
// worker a path that no longer exists and the child dies
|
|
1043
|
+
// with `ERR_MODULE_NOT_FOUND` before the supervisor can
|
|
1044
|
+
// recover. Cheap stat, big DX win.
|
|
1045
|
+
const bundlePresent = existing
|
|
1046
|
+
? fs.existsSync(existing.bootstrapPath)
|
|
1047
|
+
: false;
|
|
1048
|
+
if (existing && existingWatch && bundlePresent) {
|
|
1049
|
+
// Restart path — keep the already-built bundle and live watch.
|
|
1050
|
+
this.logger.debug({ app: entry.name, modules: existing.modulePaths.size }, 'Reusing existing esbuild bundle + watch (restart path)');
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
if (existing && existingWatch && !bundlePresent) {
|
|
1054
|
+
// Bundle was deleted out from under us. Clear the watch
|
|
1055
|
+
// and the cached BuildResult so the next call rebuilds
|
|
1056
|
+
// from cold and re-arms the watcher cleanly.
|
|
1057
|
+
this.logger.warn({ app: entry.name, bootstrapPath: existing.bootstrapPath }, 'Cached bundle missing on disk — forcing rebuild');
|
|
1058
|
+
try {
|
|
1059
|
+
await this.buildService.unwatchApp?.(entry.name);
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
this.logger.debug({ err, app: entry.name }, 'unwatchApp failed (non-fatal)');
|
|
1063
|
+
}
|
|
1064
|
+
this.buildResults.delete(entry.name);
|
|
1065
|
+
}
|
|
1066
|
+
// First-start path — build everything from cold and arm the watcher.
|
|
1067
|
+
const buildResult = await this.buildService.buildApp(entry.name, bootstrapAbsPath, definition);
|
|
1068
|
+
this.buildResults.set(entry.name, buildResult);
|
|
1069
|
+
this.logger.info({ app: entry.name, modules: buildResult.modulePaths.size }, 'Built entry points via esbuild');
|
|
1070
|
+
// Start esbuild watch for incremental rebuilds on source changes.
|
|
1071
|
+
// On rebuild → fire a restart trigger; the coalescer takes it
|
|
1072
|
+
// from there.
|
|
1073
|
+
await this.buildService.watchApp(entry.name, bootstrapAbsPath, definition, () => {
|
|
1074
|
+
// Gate: skip if the app is in a non-running state — repeated
|
|
1075
|
+
// restarts of an escalated/errored process just churn forever
|
|
1076
|
+
// without forward progress (Fix #248).
|
|
1077
|
+
// Skip restart when the app is in a terminal non-running
|
|
1078
|
+
// state. Includes 'crashed' (supervisor escalated after
|
|
1079
|
+
// exceeding its restart budget) and 'errored' (start
|
|
1080
|
+
// failed). 'stopped' means operator explicitly stopped it.
|
|
1081
|
+
// In all three cases, restarting just churns a known-broken
|
|
1082
|
+
// process; the operator must fix the underlying issue and
|
|
1083
|
+
// call `omnitron start <app>` to clear the state.
|
|
1084
|
+
const status = this.getAppStatus(entry.name);
|
|
1085
|
+
if (status === 'errored' || status === 'crashed' || status === 'stopped') {
|
|
1086
|
+
this.logger.warn({ app: entry.name, status }, 'esbuild rebuild detected but app is not running — skipping restart');
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
this.logger.info({ app: entry.name }, 'esbuild rebuild detected — restarting');
|
|
1090
|
+
this.metricsBridge?.recordEsbuildBuild(entry.name, 'rebuild', 0, true);
|
|
1091
|
+
this.restartApp(entry.name).catch((err) => {
|
|
1092
|
+
this.logger.error({ app: entry.name, error: err.message }, 'Restart after esbuild rebuild failed');
|
|
1093
|
+
});
|
|
605
1094
|
});
|
|
606
|
-
}
|
|
1095
|
+
}
|
|
607
1096
|
}
|
|
608
1097
|
catch (err) {
|
|
609
|
-
|
|
1098
|
+
// The "fall back to tsx" wording was misleading — the orchestrator
|
|
1099
|
+
// does not inject a tsx loader into spawned children, so without
|
|
1100
|
+
// a build the bootstrap-process will try to `import` the source
|
|
1101
|
+
// .ts file (referenced as `./app.module.js` in the topology) and
|
|
1102
|
+
// fail with a cryptic "Cannot find module" downstream. Surface
|
|
1103
|
+
// the build error directly so the operator sees the actual
|
|
1104
|
+
// problem on `omnitron start/restart`.
|
|
1105
|
+
this.logger.error({ app: entry.name, error: err.message }, 'esbuild build failed — app cannot start without bundled output');
|
|
1106
|
+
throw new Error(`Cannot launch '${entry.name}' in dev mode: esbuild build failed (${err.message}). ` +
|
|
1107
|
+
`Fix the build error and retry, or run with bundled output present in apps/${entry.name}/.omnitron-build/.`);
|
|
610
1108
|
}
|
|
611
1109
|
}
|
|
612
1110
|
const buildResult = this.buildResults.get(entry.name);
|
|
@@ -634,10 +1132,11 @@ export class OrchestratorService extends EventEmitter {
|
|
|
634
1132
|
*/
|
|
635
1133
|
async launchTopology(entry, handle, config, bootstrapAbsPath, topology, buildResult) {
|
|
636
1134
|
const policy = buildRestartPolicy(entry, config);
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1135
|
+
// Wait for daemon Netron to be wired up. Cheap no-op if already ready;
|
|
1136
|
+
// otherwise blocks (with timeout) instead of immediately failing — the
|
|
1137
|
+
// race between daemon startup and stack auto-resume is benign and just
|
|
1138
|
+
// needs a single rendezvous.
|
|
1139
|
+
await this.ensureDaemonNetronReady();
|
|
641
1140
|
const serviceRouter = new ServiceRouter(this.daemonNetron, this.logger);
|
|
642
1141
|
const daemonSocketUrl = `unix://${this.daemonSocketPath}`;
|
|
643
1142
|
// Topology startup order: providers (topology.expose) before consumers (topology.access).
|
|
@@ -712,6 +1211,12 @@ export class OrchestratorService extends EventEmitter {
|
|
|
712
1211
|
// pm.pool() returns a Proxy wrapping ProcessPool — execute() dispatches to pool workers
|
|
713
1212
|
const pool = await this.pm.pool(BOOTSTRAP_PROCESS_PATH, poolOptions);
|
|
714
1213
|
poolNames.push(procEntry.name);
|
|
1214
|
+
// Track the pool on the AppHandle so `getInfo()` can report the
|
|
1215
|
+
// actual worker count for pool-managed topology entries. Without
|
|
1216
|
+
// this the orchestrator falls back to `supervisor.getChildProcessId()`
|
|
1217
|
+
// which returns null for pool workers (they aren't supervisor
|
|
1218
|
+
// children) and the entry is mis-reported as `status: 'stopped'`.
|
|
1219
|
+
handle.topologyPools.set(procEntry.name, pool);
|
|
715
1220
|
// Auto-discover @Service metadata from pool workers and expose via ServiceRouter.
|
|
716
1221
|
// No manual `exports` needed — @Service decorator IS the source of truth.
|
|
717
1222
|
// Requires explicit `topology: { expose: true }` — opt-in for security.
|
|
@@ -781,11 +1286,12 @@ export class OrchestratorService extends EventEmitter {
|
|
|
781
1286
|
window: policy.window ?? config.supervision.window,
|
|
782
1287
|
backoff: policy.backoff ?? config.supervision.backoff,
|
|
783
1288
|
children,
|
|
784
|
-
onChildCrash
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1289
|
+
// No custom onChildCrash — supervisor's built-in budget gate
|
|
1290
|
+
// (process-supervisor.ts:524-531) does what we want:
|
|
1291
|
+
// * within maxRestarts/window → RESTART
|
|
1292
|
+
// * over budget → ESCALATE (if child is critical) or IGNORE
|
|
1293
|
+
// Wiring an unconditional RESTART here masked the budget and
|
|
1294
|
+
// turned every crashing child into an infinite respawn storm.
|
|
789
1295
|
};
|
|
790
1296
|
// Create supervisor WITHOUT starting — wire events first to avoid race condition
|
|
791
1297
|
const supervisor = this.pm.createSupervisor(supervisorConfig);
|
|
@@ -798,6 +1304,7 @@ export class OrchestratorService extends EventEmitter {
|
|
|
798
1304
|
handle.markOnline(childPid ?? process.pid);
|
|
799
1305
|
}
|
|
800
1306
|
this.persistState();
|
|
1307
|
+
this.startPostOnlineCooldown(entry.name);
|
|
801
1308
|
this.emit('app:online', entry.name);
|
|
802
1309
|
});
|
|
803
1310
|
// Set supervisor on handle BEFORE start so attachLogCapture can access it
|
|
@@ -876,11 +1383,12 @@ export class OrchestratorService extends EventEmitter {
|
|
|
876
1383
|
window: policy.window ?? config.supervision.window,
|
|
877
1384
|
backoff: policy.backoff ?? config.supervision.backoff,
|
|
878
1385
|
children: [childConfig],
|
|
879
|
-
onChildCrash
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1386
|
+
// No custom onChildCrash — supervisor's built-in budget gate
|
|
1387
|
+
// (process-supervisor.ts:524-531) does what we want:
|
|
1388
|
+
// * within maxRestarts/window → RESTART
|
|
1389
|
+
// * over budget → ESCALATE (if child is critical) or IGNORE
|
|
1390
|
+
// Wiring an unconditional RESTART here masked the budget and
|
|
1391
|
+
// turned every crashing child into an infinite respawn storm.
|
|
884
1392
|
};
|
|
885
1393
|
// Create supervisor WITHOUT starting — wire events first to avoid race condition
|
|
886
1394
|
const supervisor = this.pm.createSupervisor(supervisorConfig);
|
|
@@ -895,6 +1403,7 @@ export class OrchestratorService extends EventEmitter {
|
|
|
895
1403
|
handle.markOnline(childPid ?? process.pid);
|
|
896
1404
|
}
|
|
897
1405
|
this.persistState();
|
|
1406
|
+
this.startPostOnlineCooldown(entry.name);
|
|
898
1407
|
this.emit('app:online', entry.name);
|
|
899
1408
|
});
|
|
900
1409
|
// Set supervisor on handle BEFORE start so attachLogCapture can access it
|
|
@@ -1095,19 +1604,41 @@ export class OrchestratorService extends EventEmitter {
|
|
|
1095
1604
|
if (handle.topologyProcesses && handle.topologyProcesses.length > 0 && handle.supervisor) {
|
|
1096
1605
|
info.processes = [];
|
|
1097
1606
|
for (const topo of handle.topologyProcesses) {
|
|
1098
|
-
const
|
|
1099
|
-
// Match topology entry to supervisor child by name
|
|
1100
|
-
const childName = childNames.find((cn) => cn.includes(topo.name)) ?? topo.name;
|
|
1101
|
-
const processId = handle.supervisor.getChildProcessId(childName);
|
|
1607
|
+
const isPoolEntry = (topo.instances ?? 1) > 1;
|
|
1102
1608
|
let childPid = null;
|
|
1103
1609
|
let childStatus = 'stopped';
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1610
|
+
if (isPoolEntry) {
|
|
1611
|
+
// Pool-managed topology — the supervisor doesn't know about
|
|
1612
|
+
// these (workers are owned by ProcessPool). Read directly
|
|
1613
|
+
// from the pool ref we stashed at creation time.
|
|
1614
|
+
const pool = handle.topologyPools.get(topo.name);
|
|
1615
|
+
if (pool && pool.size > 0) {
|
|
1616
|
+
childStatus = 'online';
|
|
1617
|
+
// Surface the first worker's PID. Pool workers are
|
|
1618
|
+
// interchangeable so any one is representative; the CLI
|
|
1619
|
+
// prints "PID" as a quick liveness signal, not a unique
|
|
1620
|
+
// identifier.
|
|
1621
|
+
const workerIds = pool.getWorkerIds();
|
|
1622
|
+
if (workerIds.length > 0) {
|
|
1623
|
+
const firstId = workerIds[0];
|
|
1624
|
+
const wh = this.pm.getWorkerHandle(firstId);
|
|
1625
|
+
childPid = wh?.pid ?? null;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
// Single-instance topology — supervisor manages the child directly.
|
|
1631
|
+
const childNames = handle.supervisor.getChildNames();
|
|
1632
|
+
const childName = childNames.find((cn) => cn.includes(topo.name)) ?? topo.name;
|
|
1633
|
+
const processId = handle.supervisor.getChildProcessId(childName);
|
|
1634
|
+
if (processId) {
|
|
1635
|
+
const wh = this.pm.getWorkerHandle(processId);
|
|
1636
|
+
childPid = wh?.pid ?? null;
|
|
1637
|
+
childStatus = childPid ? 'online' : 'stopped';
|
|
1638
|
+
}
|
|
1108
1639
|
}
|
|
1109
1640
|
// Derive process type from declarations for backward-compatible DTO
|
|
1110
|
-
const derivedType = topo.transports ? 'server' :
|
|
1641
|
+
const derivedType = topo.transports ? 'server' : isPoolEntry ? 'worker' : 'custom';
|
|
1111
1642
|
info.processes.push({
|
|
1112
1643
|
name: topo.name,
|
|
1113
1644
|
type: derivedType,
|