Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
f1d685b819 | |||
61c4aabba3 | |||
f10a7847c2 | |||
3a39fbd65f | |||
e208384d41 | |||
c9d924811d | |||
9473924fcc | |||
a0e7408c1a | |||
6e39b1db8f | |||
ee4532221a | |||
e39173a827 | |||
6f14033d9b | |||
1c4ffbb612 | |||
0a75c4cf76 | |||
8f31672a67 | |||
b3087831e2 | |||
4160b3f031 | |||
fa50ce40c8 | |||
8f96118e0c | |||
b210efde2a | |||
d8709d8b94 | |||
43799f3431 | |||
f4cbdd51e1 | |||
1340c1c248 | |||
92a6ecac71 | |||
5e26b0ab5f | |||
e09cf38f30 | |||
c694672438 | |||
3b21a338fb | |||
28680309ad |
129
changelog.md
129
changelog.md
@@ -1,5 +1,134 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-09-03 - 5.10.2 - fix(processmonitor)
|
||||
Bump smartdaemon and stop aggressive pidusage cache clearing in ProcessMonitor
|
||||
|
||||
- Update dependency @push.rocks/smartdaemon from ^2.0.9 to ^2.1.0 in package.json.
|
||||
- Remove per-PID pidusage.clear calls in ts/daemon/processmonitor.ts (getProcessGroupStats) to avoid potential errors or unexpected behavior from manually clearing pidusage cache.
|
||||
|
||||
## 2025-09-03 - 5.10.1 - fix(processmonitor)
|
||||
Skip null pidusage entries when aggregating process-group memory/CPU to avoid errors
|
||||
|
||||
- Add defensive check for null/undefined entries returned by pidusage before accessing memory/cpu fields
|
||||
- Log a debug message when an individual process stat is null (process may have exited)
|
||||
- Improve robustness of ProcessMonitor.getProcessGroupStats to prevent runtime exceptions during aggregation
|
||||
|
||||
## 2025-09-01 - 5.10.0 - feat(daemon)
|
||||
Add crash log manager with rotation and integrate crash logging; improve IPC & process listener cleanup
|
||||
|
||||
- Introduce CrashLogManager to create formatted crash reports, persist them to disk and rotate old logs (max 100)
|
||||
- Persist recent process logs, include metadata (exit code, signal, restart attempts, memory) and human-readable sizes in crash reports
|
||||
- Integrate crash logging into ProcessMonitor: save crash logs on non-zero exits and errors, and persist/rotate logs
|
||||
- Improve ProcessMonitor and ProcessWrapper by tracking and removing event listeners to avoid memory leaks
|
||||
- Clear pidusage cache more aggressively to prevent stale entries
|
||||
- Enhance TspmIpcClient to store/remove lifecycle event handlers on disconnect to avoid dangling listeners
|
||||
- Add tests and utilities: test/test.crashlog.direct.ts, test/test.crashlog.manual.ts and test/test.crashlog.ts to validate crash log creation and rotation
|
||||
|
||||
## 2025-08-31 - 5.9.0 - feat(cli)
|
||||
Add interactive edit flow to CLI and improve UX
|
||||
|
||||
- Add -i / --interactive flag to tspm add to open an interactive editor immediately after adding a process
|
||||
- Implement interactiveEditProcess helper (smartinteract-based) to provide interactive editing for process configs
|
||||
- Enable tspm edit to launch the interactive editor (replaces prior placeholder flow)
|
||||
- Improve user-facing message when no processes are configured in tspm list
|
||||
- Lower verbosity for missing saved configs on daemon startup (changed logger.info → logger.debug)
|
||||
|
||||
## 2025-08-31 - 5.8.0 - feat(core)
|
||||
Add core TypeScript TSPM implementation: CLI, daemon, client, process management and tests
|
||||
|
||||
- Add CLI entrypoint and command set (start/stop/add/list/logs/daemon/service/stats/reset and batch ops)
|
||||
- Add daemon implementation with ProcessManager, ProcessMonitor, ProcessWrapper, LogPersistence and config storage
|
||||
- Add IPC client (tspmIpcClient) and TspmServiceManager for systemd integration using smartipc/smartdaemon
|
||||
- Introduce shared protocol types, process ID helpers and standardized error codes for stable IPC
|
||||
- Include tests and test assets for daemon, integration and IPC client scenarios
|
||||
- Add README and package metadata (package.json, npmextra.json, commitinfo)
|
||||
|
||||
## 2025-08-31 - 5.7.0 - feat(cli)
|
||||
Add 'stats' CLI command and daemon stats aggregation; fix process manager & wrapper state handling
|
||||
|
||||
- Add new 'stats' CLI command to show daemon + process statistics (memory, CPU, uptime, logs in memory, paths, configs) and include it in the default help output
|
||||
- Implement daemon-side aggregation for logs-in-memory, per-process log counts/bytes, and expose tspmDir/socket/pidFile and config counts in daemon:status
|
||||
- Enhance startById handler to detect already-running monitors and return current status/pid instead of attempting to restart
|
||||
- Improve ProcessManager start/restart/stop behavior: if an existing monitor exists but is not running, restart it; ensure PID and status are updated consistently (clear PID on stop)
|
||||
- Fix ProcessWrapper lifecycle handling: clear internal process reference on exit, improve isRunning() and getPid() semantics to reflect actual runtime state
|
||||
- Update IPC types to include optional metadata fields (paths, configs, logsInMemory) in DaemonStatusResponse
|
||||
|
||||
## 2025-08-31 - 5.6.2 - fix(processmanager)
|
||||
Improve process lifecycle handling and cleanup in daemon, monitors and wrappers
|
||||
|
||||
- StartAll: when a monitor exists but is not running, restart it instead of skipping — ensures saved processes are reliably brought online.
|
||||
- ProcessMonitor.stop: cancel any pending restart timers to prevent stray restarts after explicit stop.
|
||||
- ProcessWrapper: add killProcessTree helper and use it for graceful (SIGTERM) and force (SIGKILL) shutdowns to reliably signal child processes.
|
||||
- Daemon stopAll: yield briefly after stopping processes and inspect monitors (not only processInfo) to accurately report stopped vs failed processes.
|
||||
|
||||
## 2025-08-31 - 5.6.1 - fix(daemon)
|
||||
Ensure robust process shutdown and improve logs/subscriber diagnostics
|
||||
|
||||
- Make ProcessWrapper.stop asynchronous and awaitable to avoid race conditions when stopping processes
|
||||
- Signal entire process groups on POSIX (kill by negative PID) and fall back to per-PID signalling; escalate to SIGKILL after a timeout
|
||||
- Await processWrapper.stop() from ProcessMonitor when enforcing memory limits or handling exits/errors to ensure child processes are cleaned up
|
||||
- Add logs:subscribers IPC endpoint and corresponding types to inspect current subscribers for a process log topic
|
||||
- Add optional CLI debug output in logs command (enabled via TSPM_DEBUG=true) to print subscriber counts and details
|
||||
- Support passing request.lines to getLogs handler in daemon to limit returned log entries
|
||||
|
||||
## 2025-08-30 - 5.6.0 - feat(processmonitor)
|
||||
Add CPU monitoring and display CPU in process list
|
||||
|
||||
- CLI: show a CPU column in the `tspm list` output (adds formatting and placeholder name display)
|
||||
- Daemon: ProcessMonitor now collects CPU usage for the process group in addition to memory
|
||||
- Daemon: ProcessMonitor exposes getLastCpuUsage() and ProcessManager syncs CPU values into IProcessInfo
|
||||
- Non-breaking: UI and internal stats enriched to surface CPU metrics for processes
|
||||
|
||||
## 2025-08-30 - 5.5.0 - feat(logs)
|
||||
Improve logs streaming and backlog delivery; add CLI filters and ndjson output
|
||||
|
||||
- CLI: add new logs options: --since, --stderr-only, --stdout-only and --ndjson; enhance streaming output and gap detection
|
||||
- CLI: fetch backlog conditionally (honoring --since) and print filtered results before live streaming
|
||||
- Client: add TspmIpcClient.requestLogsBacklogStream, onStream and onBacklogTopic helpers to receive backlog chunks and streams
|
||||
- Daemon: add logs:subscribe IPC handler to stream backlog entries to requesting client in small batches
|
||||
- Protocol: extend IPC types with LogsSubscribeRequest/Response and register 'logs:subscribe' method
|
||||
- Dependency: bump @push.rocks/smartipc to ^2.3.0 to support the streaming/IPC changes
|
||||
|
||||
## 2025-08-30 - 5.4.2 - fix(cli/process/logs)
|
||||
Reset log sequence on process restart to avoid false log gap warnings
|
||||
|
||||
- Track process runId when streaming logs and initialize lastRunId from fetched logs
|
||||
- When a new runId is detected, reset lastSeq so that subsequent streamed logs are accepted (prevents spurious gap warnings)
|
||||
- Emit an informational message when a restart/runId change is detected to aid debugging of log streams
|
||||
|
||||
## 2025-08-30 - 5.4.1 - fix(processmonitor)
|
||||
Bump tsbuild devDependency and relax ps-tree callback typing in ProcessMonitor
|
||||
|
||||
- Update devDependency @git.zone/tsbuild from ^2.6.7 to ^2.6.8
|
||||
- Change psTree callback types in ts/daemon/processmonitor.ts to accept any error and ReadonlyArray for children to improve type compatibility
|
||||
|
||||
## 2025-08-30 - 5.4.0 - feat(daemon)
|
||||
Add CLI systemd service refresh on version mismatch and fix daemon memory leak; update dependencies
|
||||
|
||||
- CLI: when client and daemon versions differ, prompt to refresh the systemd service and optionally disable/enable the service automatically
|
||||
- Daemon: clear pidusage state for PIDs on process exit/stop to prevent memory leaks in long-running monitors
|
||||
- Client: expose smartdaemon in client plugin exports and fix import path for tspm.servicemanager
|
||||
- Package: tighten dependency ranges (set specific versions) and add @types for pidusage and ps-tree
|
||||
- Misc: ensure IPC disconnects and PID/socket handling improvements were integrated alongside the above changes
|
||||
|
||||
## 2025-08-30 - 5.3.2 - fix(daemon)
|
||||
Improve daemon log delivery and process monitor memory accounting; gate debug output and update tests to numeric ProcessId
|
||||
|
||||
- Deliver process logs only to subscribed clients instead of broadcasting to all connections (reduce unnecessary IPC traffic and noise)
|
||||
- Implement incremental log memory accounting in ProcessMonitor using an estimateLogSize helper and WeakMap to avoid repeated JSON.stringify and reduce CPU/memory overhead
|
||||
- Seed the incremental size map when loading persisted logs so memory accounting is accurate after restart
|
||||
- Trim logs incrementally by subtracting estimated sizes of removed entries (avoids O(n) recalculation)
|
||||
- Gate verbose console/debug output behind TSPM_DEBUG to prevent spamming in normal runs (applies to ProcessWrapper and ProcessMonitor)
|
||||
- Improve process wrapper stdout/stderr debug logging to be conditional on debug mode
|
||||
- Update tests to use numeric ProcessId via toProcessId(...) for consistency with typed IDs
|
||||
|
||||
## 2025-08-30 - 5.3.1 - fix(client(tspmIpcClient))
|
||||
Use bare topic names for IPC client subscribe/unsubscribe to fix log subscription issues
|
||||
|
||||
- Updated ts/client/tspm.ipcclient.ts to call ipcClient.subscribe/unsubscribe with the bare topic (e.g. 'logs.<id>') instead of prefixed 'topic:<...>'.
|
||||
- Added comments clarifying that the IpcClient registers the 'topic:' prefix internally.
|
||||
- Fixes incorrect topic registration that could prevent log streaming handlers from receiving messages.
|
||||
|
||||
## 2025-08-30 - 5.3.0 - feat(cli/daemon/processmonitor)
|
||||
Add flexible target resolution and search command; improve restart/backoff and error handling
|
||||
|
||||
|
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@git.zone/tspm",
|
||||
"version": "5.3.0",
|
||||
"version": "5.10.2",
|
||||
"private": false,
|
||||
"description": "a no fuzz process manager",
|
||||
"main": "dist_ts/index.js",
|
||||
@@ -24,7 +24,7 @@
|
||||
"tspm": "./cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.6.7",
|
||||
"@git.zone/tsbuild": "^2.6.8",
|
||||
"@git.zone/tsbundle": "^2.5.1",
|
||||
"@git.zone/tsrun": "^1.2.46",
|
||||
"@git.zone/tstest": "^2.3.5",
|
||||
@@ -35,11 +35,13 @@
|
||||
"@push.rocks/npmextra": "^5.3.3",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/smartcli": "^4.0.11",
|
||||
"@push.rocks/smartdaemon": "^2.0.9",
|
||||
"@push.rocks/smartdaemon": "^2.1.0",
|
||||
"@push.rocks/smartfile": "^11.2.7",
|
||||
"@push.rocks/smartinteract": "^2.0.16",
|
||||
"@push.rocks/smartipc": "^2.2.2",
|
||||
"@push.rocks/smartipc": "^2.3.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@types/pidusage": "^2.0.5",
|
||||
"@types/ps-tree": "^1.1.6",
|
||||
"pidusage": "^4.0.1",
|
||||
"ps-tree": "^1.2.0",
|
||||
"tsx": "^4.20.5"
|
||||
|
206
pnpm-lock.yaml
generated
206
pnpm-lock.yaml
generated
@@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^4.0.11
|
||||
version: 4.0.11
|
||||
'@push.rocks/smartdaemon':
|
||||
specifier: ^2.0.9
|
||||
version: 2.0.9
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
'@push.rocks/smartfile':
|
||||
specifier: ^11.2.7
|
||||
version: 11.2.7
|
||||
@@ -27,11 +27,17 @@ importers:
|
||||
specifier: ^2.0.16
|
||||
version: 2.0.16
|
||||
'@push.rocks/smartipc':
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@types/pidusage':
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
'@types/ps-tree':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
pidusage:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -43,8 +49,8 @@ importers:
|
||||
version: 4.20.5
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^2.6.7
|
||||
version: 2.6.7
|
||||
specifier: ^2.6.8
|
||||
version: 2.6.8
|
||||
'@git.zone/tsbundle':
|
||||
specifier: ^2.5.1
|
||||
version: 2.5.1
|
||||
@@ -530,8 +536,8 @@ packages:
|
||||
'@esm-bundle/chai@4.3.4-fix.0':
|
||||
resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==}
|
||||
|
||||
'@git.zone/tsbuild@2.6.7':
|
||||
resolution: {integrity: sha512-nLRYk1V4gxdEAp5mbLYNdr/in9mFA26L4MPKBKqzASID4lXSYya5sDbLRdDTv+mD0ZRBgdn6e+WMylA0SU4hSw==}
|
||||
'@git.zone/tsbuild@2.6.8':
|
||||
resolution: {integrity: sha512-g1z7+MxiYD0xMfuqn8NSWitbfK1OaF0Qolmw7WOmUsHmNF60T1AR02Lo4DtNmnjSpchA+xzDFAQzL1xTcQA39w==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.5.1':
|
||||
@@ -769,8 +775,8 @@ packages:
|
||||
'@push.rocks/isounique@1.0.5':
|
||||
resolution: {integrity: sha512-Z0BVqZZOCif1THTbIKWMgg0wxCzt9CyBtBBqQJiZ+jJ0KlQFrQHNHrPt81/LXe/L4x0cxWsn0bpL6W5DNSvNLw==}
|
||||
|
||||
'@push.rocks/levelcache@3.1.1':
|
||||
resolution: {integrity: sha512-+JpDNEt+EuvmbtADGH9SkODxBy+slHDDzs43mAbuMbwpVvi6uNuMK0Mkhrfz9UFpxUSp+cJE/jl/OxdpD0xL1A==}
|
||||
'@push.rocks/levelcache@3.2.0':
|
||||
resolution: {integrity: sha512-Ch0Oguta2I0SVi704kHghhBcgfyfS92ua1elRu9d8X1/9LMRYuqvvBAnyXyFxQzI3S8q8QC6EkRdd8CAAYSzRg==}
|
||||
|
||||
'@push.rocks/lik@6.1.0':
|
||||
resolution: {integrity: sha512-BoSAIRFNryQ8Sd5EP+35ZBj6vAQ1C60/XjZIO2O65XDyLG8xz7xJ+u5Wm8/fjIJ0WX3h8GkkaCz2tJM34nFT3A==}
|
||||
@@ -805,6 +811,9 @@ packages:
|
||||
'@push.rocks/smartcache@1.0.16':
|
||||
resolution: {integrity: sha512-UAXf74eDuH4/RebJhydIbHlYVR3ACYJjniEY/9ZePblu7bIPgwFZqLBE9g1lcKVogbH9yY62dk3rSpgBzenyfQ==}
|
||||
|
||||
'@push.rocks/smartcache@1.0.18':
|
||||
resolution: {integrity: sha512-3+cmLu9chbnmi4yD4kjlFP/Tn4NReaZIoicEcGTtwbcokTrSDMs3YPdJzIpDZkAs83PW7OcVSHa3Ak5KU5OWzA==}
|
||||
|
||||
'@push.rocks/smartchok@1.1.1':
|
||||
resolution: {integrity: sha512-WmNigGmn1muBJMANVuJb4F8x3TzgYrnn6YZm6ixTsG+0WFbYevivEwp+J4S7npobLHsR7ynf+Ky8LxRYmsL50A==}
|
||||
|
||||
@@ -817,8 +826,8 @@ packages:
|
||||
'@push.rocks/smartcrypto@2.0.4':
|
||||
resolution: {integrity: sha512-1+/5bsjyataf5uUkUNnnVXGRAt+gHVk1KDzozjTqgqJxHvQk1d9fVDohL6CxUhUucTPtu5VR5xNBiV8YCDuGyw==}
|
||||
|
||||
'@push.rocks/smartdaemon@2.0.9':
|
||||
resolution: {integrity: sha512-TJd2N/vMAY3qpuy7ub0btNsSqdy7oU/hF/D+BbmfJVAiTKpvlgtCXKE5POwfuee03SONyh8LuH5Ey1ycIpsEHA==}
|
||||
'@push.rocks/smartdaemon@2.1.0':
|
||||
resolution: {integrity: sha512-cxc05jvA/frb3rJ5EdQkkfbJXiFC33u57LmOaBye6Hynj4w/ZZjph7WLAkp6Yx8+75Ldajm6LXIRxn91+RbDeQ==}
|
||||
|
||||
'@push.rocks/smartdata@5.16.4':
|
||||
resolution: {integrity: sha512-COiKw8yk9iAcLN44WmZHG8Gi0v+HGkgM8Osoq7Cns+UsOA+grPepqbN2r0XPG1fm5vOdJcaydi2ZU0xrnbGVvQ==}
|
||||
@@ -832,6 +841,9 @@ packages:
|
||||
'@push.rocks/smartenv@5.0.13':
|
||||
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
|
||||
|
||||
'@push.rocks/smarterror@2.0.1':
|
||||
resolution: {integrity: sha512-iCcH1D8tlDJgMFsaJ6lhdOTKhbU0KoprNv9MRP9o7691QOx4JEDXiHtr/lNtxVo8BUtdb9CF6kazaknO9KuORA==}
|
||||
|
||||
'@push.rocks/smartexit@1.0.23':
|
||||
resolution: {integrity: sha512-WmwKYcwbHBByoABhHHB+PAjr5475AtD/xBh1mDcqPrFsOOUOZq3BBUdpq25wI3ccu/SZB5IwaimiVzadls6HkA==}
|
||||
|
||||
@@ -865,8 +877,8 @@ packages:
|
||||
'@push.rocks/smartinteract@2.0.16':
|
||||
resolution: {integrity: sha512-eltvVRRUKBKd77DSFA4DPY2g4V4teZLNe8A93CDy/WglglYcUjxMoLY/b0DFTWCWKYT+yjk6Fe6p0FRrvX9Yvg==}
|
||||
|
||||
'@push.rocks/smartipc@2.2.2':
|
||||
resolution: {integrity: sha512-pkWqp2nQH7p5zD9Efh5KNX2O0+gFWL6bxbdd6SdDh4gP8Gb0b3Sn87Tpedghpc/d+LCVql+1pUf6OlvMQpD5Yw==}
|
||||
'@push.rocks/smartipc@2.3.0':
|
||||
resolution: {integrity: sha512-/btC/DHf+2PWF6Qiq0oHHP7XHzacgYfHAShIts2ZXS+nhpvSyjucNzB2ErNUPHLMITNXGUSu5Wpt7sfvIQzxJQ==}
|
||||
|
||||
'@push.rocks/smartjson@5.0.20':
|
||||
resolution: {integrity: sha512-ogGBLyOTluphZVwBYNyjhm5sziPGuiAwWihW07OSRxD4HQUyqj9Ek6r1pqH07JUG5EbtRYivM1Yt1cCwnu3JVQ==}
|
||||
@@ -886,6 +898,9 @@ packages:
|
||||
'@push.rocks/smartlog@3.1.8':
|
||||
resolution: {integrity: sha512-j4H5x4/hEmiIO7q+/LKyX3N+AhRIOj1jDE4TvZDvujZkbT/9wEWfpO1bqeMe/EQbg1eOQMlAuyrcLXUcDICpQg==}
|
||||
|
||||
'@push.rocks/smartlog@3.1.9':
|
||||
resolution: {integrity: sha512-Lix1pazMhvnSUyj4Bt+pO+SvImw3l0dm5A0LTTx/QaSlWP8bpAQNQ+8z7wfQy3pIKFHkApxvGM6WprgCCS2itQ==}
|
||||
|
||||
'@push.rocks/smartmanifest@2.0.2':
|
||||
resolution: {integrity: sha512-QGc5C9vunjfUbYsPGz5bynV/mVmPHkrQDkWp8ZO8VJtK1GZe+njgbrNyxn2SUHR0IhSAbSXl1j4JvBqYf5eTVg==}
|
||||
|
||||
@@ -1012,6 +1027,9 @@ packages:
|
||||
'@push.rocks/tapbundle@6.0.3':
|
||||
resolution: {integrity: sha512-SuP14V6TPdtd1y1CYTvwTKJdpHa7EzY55NfaaEMxW4oRKvHgJiOiPEiR/IrtL9tSiDMSfrx12waTMgZheYaBug==}
|
||||
|
||||
'@push.rocks/taskbuffer@3.1.10':
|
||||
resolution: {integrity: sha512-jT+FxRSk0+IP17q9LD1/Ks8GJBn5TZWgLtfnKRHW/LAZ1bHX/2ARZvAV8fm1T4WMU5s7PyId+y4fkoohG/5Nkg==}
|
||||
|
||||
'@push.rocks/taskbuffer@3.1.7':
|
||||
resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
|
||||
|
||||
@@ -1255,8 +1273,8 @@ packages:
|
||||
resolution: {integrity: sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.9.0':
|
||||
resolution: {integrity: sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==}
|
||||
'@smithy/core@3.9.1':
|
||||
resolution: {integrity: sha512-E3erEn1SjPq8P9w2fPlp1+slaq6FlrRKlsaLCo0aPMY2j94lwZlwz1yqY4yDeX3+ViG+sOEPPRBZGfdciMtABA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.0.7':
|
||||
@@ -1323,16 +1341,16 @@ packages:
|
||||
resolution: {integrity: sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-endpoint@4.1.19':
|
||||
resolution: {integrity: sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==}
|
||||
'@smithy/middleware-endpoint@4.1.20':
|
||||
resolution: {integrity: sha512-6jwjI4l9LkpEN/77ylyWsA6o81nKSIj8itRjtPpVqYSf+q8b12uda0Upls5CMSDXoL/jY2gPsNj+/Tg3gbYYew==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-retry@4.1.19':
|
||||
resolution: {integrity: sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-retry@4.1.20':
|
||||
resolution: {integrity: sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==}
|
||||
'@smithy/middleware-retry@4.1.21':
|
||||
resolution: {integrity: sha512-oFpp+4JfNef0Mp2Jw8wIl1jVxjhUU3jFZkk3UTqBtU5Xp6/ahTu6yo1EadWNPAnCjKTo8QB6Q+SObX97xfMUtA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-serde@4.0.9':
|
||||
@@ -1383,8 +1401,8 @@ packages:
|
||||
resolution: {integrity: sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/smithy-client@4.5.0':
|
||||
resolution: {integrity: sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==}
|
||||
'@smithy/smithy-client@4.5.1':
|
||||
resolution: {integrity: sha512-PuvtnQgwpy3bb56YvHAP7eRwp862yJxtQno40UX9kTjjkgTlo//ov+e1IVCFTiELcAOiqF2++Y0e7eH/Zgv5Vw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.3.2':
|
||||
@@ -1423,16 +1441,16 @@ packages:
|
||||
resolution: {integrity: sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-defaults-mode-browser@4.0.27':
|
||||
resolution: {integrity: sha512-i/Fu6AFT5014VJNgWxKomBJP/GB5uuOsM4iHdcmplLm8B1eAqnRItw4lT2qpdO+mf+6TFmf6dGcggGLAVMZJsQ==}
|
||||
'@smithy/util-defaults-mode-browser@4.0.28':
|
||||
resolution: {integrity: sha512-83Iqb9c443d8S/9PD6Bb770Q3ZvCenfgJDoR98iveI+zKpu6d4mOVS2RKBU9Z4VQPbRcrRj71SY0kZePGh+wZg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-defaults-mode-node@4.0.26':
|
||||
resolution: {integrity: sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-defaults-mode-node@4.0.27':
|
||||
resolution: {integrity: sha512-3W0qClMyxl/ELqTA39aNw1N+pN0IjpXT7lPFvZ8zTxqVFP7XCpACB9QufmN4FQtd39xbgS7/Lekn7LmDa63I5w==}
|
||||
'@smithy/util-defaults-mode-node@4.0.28':
|
||||
resolution: {integrity: sha512-LzklW4HepBM198vH0C3v+WSkMHOkxu7axCEqGoKdICz3RHLq+mDs2AkDDXVtB61+SHWoiEsc6HOObzVQbNLO0Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-endpoints@3.0.7':
|
||||
@@ -1647,9 +1665,15 @@ packages:
|
||||
'@types/parse5@6.0.3':
|
||||
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
|
||||
|
||||
'@types/pidusage@2.0.5':
|
||||
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
|
||||
|
||||
'@types/ping@0.4.4':
|
||||
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
|
||||
|
||||
'@types/ps-tree@1.1.6':
|
||||
resolution: {integrity: sha512-PtrlVaOaI44/3pl3cvnlK+GxOM3re2526TJvPvh7W+keHIXdV4TE0ylpPBAcvFQCbGitaTXwL9u+RF7qtVeazQ==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
@@ -4660,26 +4684,26 @@ snapshots:
|
||||
'@aws-sdk/util-user-agent-browser': 3.734.0
|
||||
'@aws-sdk/util-user-agent-node': 3.758.0
|
||||
'@smithy/config-resolver': 4.1.5
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/fetch-http-handler': 5.1.1
|
||||
'@smithy/hash-node': 4.0.5
|
||||
'@smithy/invalid-dependency': 4.0.5
|
||||
'@smithy/middleware-content-length': 4.0.5
|
||||
'@smithy/middleware-endpoint': 4.1.19
|
||||
'@smithy/middleware-retry': 4.1.20
|
||||
'@smithy/middleware-endpoint': 4.1.20
|
||||
'@smithy/middleware-retry': 4.1.21
|
||||
'@smithy/middleware-serde': 4.0.9
|
||||
'@smithy/middleware-stack': 4.0.5
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/node-http-handler': 4.1.1
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/url-parser': 4.0.5
|
||||
'@smithy/util-base64': 4.0.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
'@smithy/util-body-length-node': 4.0.0
|
||||
'@smithy/util-defaults-mode-browser': 4.0.27
|
||||
'@smithy/util-defaults-mode-node': 4.0.27
|
||||
'@smithy/util-defaults-mode-browser': 4.0.28
|
||||
'@smithy/util-defaults-mode-node': 4.0.28
|
||||
'@smithy/util-endpoints': 3.0.7
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
'@smithy/util-retry': 4.0.7
|
||||
@@ -4767,26 +4791,26 @@ snapshots:
|
||||
'@aws-sdk/util-user-agent-browser': 3.734.0
|
||||
'@aws-sdk/util-user-agent-node': 3.758.0
|
||||
'@smithy/config-resolver': 4.1.5
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/fetch-http-handler': 5.1.1
|
||||
'@smithy/hash-node': 4.0.5
|
||||
'@smithy/invalid-dependency': 4.0.5
|
||||
'@smithy/middleware-content-length': 4.0.5
|
||||
'@smithy/middleware-endpoint': 4.1.19
|
||||
'@smithy/middleware-retry': 4.1.20
|
||||
'@smithy/middleware-endpoint': 4.1.20
|
||||
'@smithy/middleware-retry': 4.1.21
|
||||
'@smithy/middleware-serde': 4.0.9
|
||||
'@smithy/middleware-stack': 4.0.5
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/node-http-handler': 4.1.1
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/url-parser': 4.0.5
|
||||
'@smithy/util-base64': 4.0.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
'@smithy/util-body-length-node': 4.0.0
|
||||
'@smithy/util-defaults-mode-browser': 4.0.27
|
||||
'@smithy/util-defaults-mode-node': 4.0.27
|
||||
'@smithy/util-defaults-mode-browser': 4.0.28
|
||||
'@smithy/util-defaults-mode-node': 4.0.28
|
||||
'@smithy/util-endpoints': 3.0.7
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
'@smithy/util-retry': 4.0.7
|
||||
@@ -4842,12 +4866,12 @@ snapshots:
|
||||
'@aws-sdk/core@3.758.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.734.0
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/property-provider': 4.0.5
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/signature-v4': 5.1.3
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
fast-xml-parser: 4.4.1
|
||||
@@ -4908,7 +4932,7 @@ snapshots:
|
||||
'@smithy/node-http-handler': 4.1.1
|
||||
'@smithy/property-provider': 4.0.5
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/util-stream': 4.2.4
|
||||
tslib: 2.8.1
|
||||
@@ -5082,7 +5106,7 @@ snapshots:
|
||||
'@aws-sdk/credential-provider-web-identity': 3.758.0
|
||||
'@aws-sdk/nested-clients': 3.758.0
|
||||
'@aws-sdk/types': 3.734.0
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/credential-provider-imds': 4.0.7
|
||||
'@smithy/property-provider': 4.0.5
|
||||
'@smithy/types': 4.3.2
|
||||
@@ -5201,7 +5225,7 @@ snapshots:
|
||||
'@aws-sdk/core': 3.758.0
|
||||
'@aws-sdk/types': 3.734.0
|
||||
'@aws-sdk/util-endpoints': 3.743.0
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
@@ -5232,26 +5256,26 @@ snapshots:
|
||||
'@aws-sdk/util-user-agent-browser': 3.734.0
|
||||
'@aws-sdk/util-user-agent-node': 3.758.0
|
||||
'@smithy/config-resolver': 4.1.5
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/fetch-http-handler': 5.1.1
|
||||
'@smithy/hash-node': 4.0.5
|
||||
'@smithy/invalid-dependency': 4.0.5
|
||||
'@smithy/middleware-content-length': 4.0.5
|
||||
'@smithy/middleware-endpoint': 4.1.19
|
||||
'@smithy/middleware-retry': 4.1.20
|
||||
'@smithy/middleware-endpoint': 4.1.20
|
||||
'@smithy/middleware-retry': 4.1.21
|
||||
'@smithy/middleware-serde': 4.0.9
|
||||
'@smithy/middleware-stack': 4.0.5
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/node-http-handler': 4.1.1
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/url-parser': 4.0.5
|
||||
'@smithy/util-base64': 4.0.0
|
||||
'@smithy/util-body-length-browser': 4.0.0
|
||||
'@smithy/util-body-length-node': 4.0.0
|
||||
'@smithy/util-defaults-mode-browser': 4.0.27
|
||||
'@smithy/util-defaults-mode-node': 4.0.27
|
||||
'@smithy/util-defaults-mode-browser': 4.0.28
|
||||
'@smithy/util-defaults-mode-node': 4.0.28
|
||||
'@smithy/util-endpoints': 3.0.7
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
'@smithy/util-retry': 4.0.7
|
||||
@@ -5599,7 +5623,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 4.3.20
|
||||
|
||||
'@git.zone/tsbuild@2.6.7':
|
||||
'@git.zone/tsbuild@2.6.8':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.10.3
|
||||
'@push.rocks/early': 4.0.4
|
||||
@@ -6015,21 +6039,21 @@ snapshots:
|
||||
|
||||
'@push.rocks/isounique@1.0.5': {}
|
||||
|
||||
'@push.rocks/levelcache@3.1.1':
|
||||
'@push.rocks/levelcache@3.2.0':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartbucket': 3.3.10
|
||||
'@push.rocks/smartcache': 1.0.16
|
||||
'@push.rocks/smartcache': 1.0.18
|
||||
'@push.rocks/smartenv': 5.0.13
|
||||
'@push.rocks/smartexit': 1.0.23
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartjson': 5.0.20
|
||||
'@push.rocks/smartpath': 5.1.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartstring': 4.0.15
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
'@push.rocks/taskbuffer': 3.1.7
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
'@push.rocks/taskbuffer': 3.1.10
|
||||
'@tsclass/tsclass': 9.2.0
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
@@ -6158,6 +6182,14 @@ snapshots:
|
||||
'@pushrocks/smartpromise': 3.1.10
|
||||
'@pushrocks/smarttime': 4.0.1
|
||||
|
||||
'@push.rocks/smartcache@1.0.18':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smarterror': 2.0.1
|
||||
'@push.rocks/smarthash': 3.2.3
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
|
||||
'@push.rocks/smartchok@1.1.1':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
@@ -6190,12 +6222,12 @@ snapshots:
|
||||
'@types/node-forge': 1.3.14
|
||||
node-forge: 1.3.1
|
||||
|
||||
'@push.rocks/smartdaemon@2.0.9':
|
||||
'@push.rocks/smartdaemon@2.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartfm': 2.2.2
|
||||
'@push.rocks/smartlog': 3.1.8
|
||||
'@push.rocks/smartlog': 3.1.9
|
||||
'@push.rocks/smartlog-destination-local': 9.0.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartshell': 3.3.0
|
||||
@@ -6237,6 +6269,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smarterror@2.0.1':
|
||||
dependencies:
|
||||
clean-stack: 1.3.0
|
||||
make-error-cause: 2.3.0
|
||||
|
||||
'@push.rocks/smartexit@1.0.23':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.1.0
|
||||
@@ -6326,7 +6363,7 @@ snapshots:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
inquirer: 11.1.0
|
||||
|
||||
'@push.rocks/smartipc@2.2.2':
|
||||
'@push.rocks/smartipc@2.3.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
@@ -6371,6 +6408,19 @@ snapshots:
|
||||
'@push.rocks/webrequest': 3.0.37
|
||||
'@tsclass/tsclass': 9.2.0
|
||||
|
||||
'@push.rocks/smartlog@3.1.9':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
'@push.rocks/isounique': 1.0.5
|
||||
'@push.rocks/smartclickhouse': 2.0.17
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smarthash': 3.2.3
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
'@push.rocks/webrequest': 3.0.37
|
||||
'@tsclass/tsclass': 9.2.0
|
||||
|
||||
'@push.rocks/smartmanifest@2.0.2': {}
|
||||
|
||||
'@push.rocks/smartmarkdown@3.0.3':
|
||||
@@ -6443,7 +6493,7 @@ snapshots:
|
||||
'@push.rocks/smartnpm@2.0.6':
|
||||
dependencies:
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
'@push.rocks/levelcache': 3.1.1
|
||||
'@push.rocks/levelcache': 3.2.0
|
||||
'@push.rocks/smartarchive': 4.2.2
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -6745,6 +6795,16 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@push.rocks/taskbuffer@3.1.10':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.8
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
|
||||
'@push.rocks/taskbuffer@3.1.7':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
@@ -7011,7 +7071,7 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
uuid: 9.0.1
|
||||
|
||||
'@smithy/core@3.9.0':
|
||||
'@smithy/core@3.9.1':
|
||||
dependencies:
|
||||
'@smithy/middleware-serde': 4.0.9
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
@@ -7128,9 +7188,9 @@ snapshots:
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-endpoint@4.1.19':
|
||||
'@smithy/middleware-endpoint@4.1.20':
|
||||
dependencies:
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/middleware-serde': 4.0.9
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/shared-ini-file-loader': 4.0.5
|
||||
@@ -7153,12 +7213,12 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
uuid: 9.0.1
|
||||
|
||||
'@smithy/middleware-retry@4.1.20':
|
||||
'@smithy/middleware-retry@4.1.21':
|
||||
dependencies:
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/service-error-classification': 4.0.7
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
'@smithy/util-middleware': 4.0.5
|
||||
'@smithy/util-retry': 4.0.7
|
||||
@@ -7244,10 +7304,10 @@ snapshots:
|
||||
'@smithy/util-stream': 4.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/smithy-client@4.5.0':
|
||||
'@smithy/smithy-client@4.5.1':
|
||||
dependencies:
|
||||
'@smithy/core': 3.9.0
|
||||
'@smithy/middleware-endpoint': 4.1.19
|
||||
'@smithy/core': 3.9.1
|
||||
'@smithy/middleware-endpoint': 4.1.20
|
||||
'@smithy/middleware-stack': 4.0.5
|
||||
'@smithy/protocol-http': 5.1.3
|
||||
'@smithy/types': 4.3.2
|
||||
@@ -7301,10 +7361,10 @@ snapshots:
|
||||
bowser: 2.12.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-defaults-mode-browser@4.0.27':
|
||||
'@smithy/util-defaults-mode-browser@4.0.28':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 4.0.5
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
bowser: 2.12.1
|
||||
tslib: 2.8.1
|
||||
@@ -7320,13 +7380,13 @@ snapshots:
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-defaults-mode-node@4.0.27':
|
||||
'@smithy/util-defaults-mode-node@4.0.28':
|
||||
dependencies:
|
||||
'@smithy/config-resolver': 4.1.5
|
||||
'@smithy/credential-provider-imds': 4.0.7
|
||||
'@smithy/node-config-provider': 4.1.4
|
||||
'@smithy/property-provider': 4.0.5
|
||||
'@smithy/smithy-client': 4.5.0
|
||||
'@smithy/smithy-client': 4.5.1
|
||||
'@smithy/types': 4.3.2
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -7592,8 +7652,12 @@ snapshots:
|
||||
|
||||
'@types/parse5@6.0.3': {}
|
||||
|
||||
'@types/pidusage@2.0.5': {}
|
||||
|
||||
'@types/ping@0.4.4': {}
|
||||
|
||||
'@types/ps-tree@1.1.6': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
38
readme.md
38
readme.md
@@ -72,6 +72,7 @@ Add a new process configuration without starting it. This is the recommended way
|
||||
- `--watch` - Enable file watching for auto-restart
|
||||
- `--watch-paths <paths>` - Comma-separated paths to watch
|
||||
- `--autorestart` - Auto-restart on crash (default: true)
|
||||
- `-i, --interactive` - Enter interactive edit mode after adding
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
@@ -86,6 +87,9 @@ tspm add "tsx watch src/index.ts" --name dev-server --watch --watch-paths "src,c
|
||||
|
||||
# Add without auto-restart
|
||||
tspm add "node worker.js" --name one-time-job --autorestart false
|
||||
|
||||
# Add and immediately edit interactively
|
||||
tspm add "node server.js" --name api -i
|
||||
```
|
||||
|
||||
#### `tspm start <id|id:N|name:LABEL>`
|
||||
@@ -177,11 +181,15 @@ Watch: disabled
|
||||
|
||||
#### `tspm logs <id|id:N|name:LABEL> [options]`
|
||||
|
||||
View process logs (stdout and stderr combined).
|
||||
View and stream process logs (stdout, stderr, and system messages).
|
||||
|
||||
**Options:**
|
||||
- `--lines <n>` - Number of lines to display (default: 50)
|
||||
- `--follow` - Stream logs in real-time (like `tail -f`)
|
||||
- `--lines <n>` Number of lines to show (default: 50)
|
||||
- `--since <dur>` Only show logs since duration (e.g., `10m`, `2h`, `1d`; units: `ms|s|m|h|d`)
|
||||
- `--stderr-only` Only show stderr logs
|
||||
- `--stdout-only` Only show stdout logs
|
||||
- `--ndjson` Output each log as JSON line (timestamp in ms)
|
||||
- `--follow` Stream logs in real-time (like `tail -f`)
|
||||
|
||||
```bash
|
||||
# View last 50 lines
|
||||
@@ -190,10 +198,20 @@ tspm logs name:my-server
|
||||
# View last 100 lines
|
||||
tspm logs name:my-server --lines 100
|
||||
|
||||
# Follow logs in real-time
|
||||
# Only stderr for the last 10 minutes (as NDJSON)
|
||||
tspm logs name:my-server --since 10m --stderr-only --ndjson
|
||||
|
||||
# Follow logs in real time (prints recent lines, then streams backlog incrementally and live logs)
|
||||
tspm logs name:my-server --follow
|
||||
|
||||
# Follow only stdout since 2h ago
|
||||
tspm logs name:my-server --follow --since 2h --stdout-only
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Follow mode prints a small recent backlog, then streams older entries incrementally (to avoid large payloads) and continues with live logs.
|
||||
- Log sequences are restart-aware; TSPM detects run changes and keeps output consistent across restarts.
|
||||
|
||||
### Batch Operations
|
||||
|
||||
#### `tspm start-all`
|
||||
@@ -285,6 +303,18 @@ Processes: 5
|
||||
Socket: /home/user/.tspm/tspm.sock
|
||||
```
|
||||
|
||||
#### Version check and service refresh
|
||||
|
||||
Check CLI vs daemon versions and refresh the systemd service if they differ:
|
||||
|
||||
```bash
|
||||
tspm -v
|
||||
# tspm CLI: 5.x.y
|
||||
# Daemon: running v5.x.z (pid 1234)
|
||||
# Version mismatch detected → optionally refresh the systemd service (equivalent to `tspm disable && tspm enable`).
|
||||
```
|
||||
This is helpful after upgrades where the system service still references an older CLI path.
|
||||
|
||||
### System Service Management
|
||||
|
||||
Run TSPM as a system service (systemd) for production deployments.
|
||||
|
148
test/test.crashlog.direct.ts
Normal file
148
test/test.crashlog.direct.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
import { CrashLogManager } from '../ts/daemon/crashlogmanager.js';
|
||||
import type { IProcessLog } from '../ts/shared/protocol/ipc.types.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
async function testCrashLogManager() {
|
||||
console.log('🧪 Testing CrashLogManager directly...\n');
|
||||
|
||||
const crashLogManager = new CrashLogManager();
|
||||
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
|
||||
|
||||
// Clean up any existing crash logs
|
||||
console.log('📁 Cleaning up existing crash logs...');
|
||||
try {
|
||||
await fs.rm(crashLogsDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Create test logs
|
||||
const testLogs: IProcessLog[] = [
|
||||
{
|
||||
timestamp: Date.now() - 5000,
|
||||
message: '[TEST] Process starting up...',
|
||||
type: 'stdout'
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 4000,
|
||||
message: '[TEST] Initializing components...',
|
||||
type: 'stdout'
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 3000,
|
||||
message: '[TEST] Running main loop...',
|
||||
type: 'stdout'
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 2000,
|
||||
message: '[TEST] Warning: Memory usage high',
|
||||
type: 'stderr'
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 1000,
|
||||
message: '[TEST] Error: Unhandled exception occurred!',
|
||||
type: 'stderr'
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 500,
|
||||
message: '[TEST] Fatal: Process crashing with exit code 42',
|
||||
type: 'stderr'
|
||||
}
|
||||
];
|
||||
|
||||
// Test saving a crash log
|
||||
console.log('💾 Saving crash log...');
|
||||
await crashLogManager.saveCrashLog(
|
||||
1 as any, // ProcessId
|
||||
'test-process',
|
||||
testLogs,
|
||||
42, // exit code
|
||||
null, // signal
|
||||
3, // restart count
|
||||
1024 * 1024 * 50 // 50MB memory usage
|
||||
);
|
||||
|
||||
// Check if crash log was created
|
||||
console.log('🔍 Checking for crash log...');
|
||||
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
|
||||
console.log(` Found ${crashLogFiles.length} crash log files:`);
|
||||
crashLogFiles.forEach(file => console.log(` - ${file}`));
|
||||
|
||||
if (crashLogFiles.length === 0) {
|
||||
console.error('❌ No crash logs were created!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read and display the crash log
|
||||
const crashLogFile = crashLogFiles[0];
|
||||
const crashLogPath = plugins.path.join(crashLogsDir, crashLogFile);
|
||||
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
|
||||
|
||||
console.log('\n📋 Crash log content:');
|
||||
console.log('─'.repeat(60));
|
||||
console.log(crashLogContent);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
// Verify content
|
||||
const checks = [
|
||||
{ text: 'CRASH REPORT', found: crashLogContent.includes('CRASH REPORT') },
|
||||
{ text: 'Exit Code: 42', found: crashLogContent.includes('Exit Code: 42') },
|
||||
{ text: 'Restart Attempt: 3/10', found: crashLogContent.includes('Restart Attempt: 3/10') },
|
||||
{ text: 'Memory Usage: 50 MB', found: crashLogContent.includes('Memory Usage: 50 MB') },
|
||||
{ text: 'Fatal: Process crashing', found: crashLogContent.includes('Fatal: Process crashing') }
|
||||
];
|
||||
|
||||
console.log('\n✅ Verification:');
|
||||
checks.forEach(check => {
|
||||
console.log(` ${check.found ? '✓' : '✗'} Contains "${check.text}"`);
|
||||
});
|
||||
|
||||
const allChecksPassed = checks.every(c => c.found);
|
||||
|
||||
// Test rotation (create 100+ logs to test limit)
|
||||
console.log('\n🔄 Testing rotation (creating 105 crash logs)...');
|
||||
for (let i = 2; i <= 105; i++) {
|
||||
await crashLogManager.saveCrashLog(
|
||||
i as any,
|
||||
`test-process-${i}`,
|
||||
testLogs,
|
||||
i,
|
||||
null,
|
||||
1,
|
||||
1024 * 1024 * 10
|
||||
);
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// Check that we have exactly 100 logs (rotation working)
|
||||
const finalLogFiles = await fs.readdir(crashLogsDir);
|
||||
console.log(` After rotation: ${finalLogFiles.length} crash logs (should be 100)`);
|
||||
|
||||
if (finalLogFiles.length !== 100) {
|
||||
console.error(`❌ Rotation failed! Expected 100 logs, got ${finalLogFiles.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify oldest logs were deleted (test-process should be gone)
|
||||
const hasOriginal = finalLogFiles.some(f => f.includes('_1_test-process.log'));
|
||||
if (hasOriginal) {
|
||||
console.error('❌ Rotation failed! Oldest log still exists');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (allChecksPassed) {
|
||||
console.log('\n✅ All crash log tests passed!');
|
||||
} else {
|
||||
console.log('\n❌ Some crash log tests failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testCrashLogManager().catch(error => {
|
||||
console.error('❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
137
test/test.crashlog.manual.ts
Normal file
137
test/test.crashlog.manual.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// Test process that will crash
|
||||
const CRASH_SCRIPT = `
|
||||
setInterval(() => {
|
||||
console.log('[test] Process is running...');
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
console.error('[test] About to crash with non-zero exit code!');
|
||||
process.exit(42);
|
||||
}, 3000);
|
||||
`;
|
||||
|
||||
async function testCrashLog() {
|
||||
console.log('🧪 Testing crash log functionality...\n');
|
||||
|
||||
const crashScriptPath = plugins.path.join(paths.tspmDir, 'test-crash-script.js');
|
||||
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
|
||||
|
||||
try {
|
||||
// Clean up any existing crash logs
|
||||
console.log('📁 Cleaning up existing crash logs...');
|
||||
try {
|
||||
await fs.rm(crashLogsDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Write the crash script
|
||||
console.log('📝 Writing test crash script...');
|
||||
await fs.writeFile(crashScriptPath, CRASH_SCRIPT);
|
||||
|
||||
// Stop any existing daemon
|
||||
console.log('🛑 Stopping any existing daemon...');
|
||||
try {
|
||||
execSync('tsx ts/cli.ts daemon stop', { stdio: 'inherit' });
|
||||
} catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Start the daemon
|
||||
console.log('🚀 Starting daemon...');
|
||||
execSync('tsx ts/cli.ts daemon start', { stdio: 'inherit' });
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Add a process that will crash
|
||||
console.log('➕ Adding crash test process...');
|
||||
const addOutput = execSync(`tsx ts/cli.ts add "node ${crashScriptPath}" --name crash-test`, { encoding: 'utf-8' });
|
||||
console.log(addOutput);
|
||||
|
||||
// Extract process ID from output
|
||||
const idMatch = addOutput.match(/Process added with ID: (\d+)/);
|
||||
if (!idMatch) {
|
||||
throw new Error('Could not extract process ID from output');
|
||||
}
|
||||
const processId = parseInt(idMatch[1]);
|
||||
console.log(` Process ID: ${processId}`);
|
||||
|
||||
// Start the process
|
||||
console.log('▶️ Starting process that will crash...');
|
||||
execSync(`tsx ts/cli.ts start ${processId}`, { stdio: 'inherit' });
|
||||
|
||||
// Wait for the process to crash (it crashes after 3 seconds)
|
||||
console.log('⏳ Waiting for process to crash...');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Check if crash log was created
|
||||
console.log('🔍 Checking for crash log...');
|
||||
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
|
||||
console.log(` Found ${crashLogFiles.length} crash log files:`);
|
||||
crashLogFiles.forEach(file => console.log(` - ${file}`));
|
||||
|
||||
if (crashLogFiles.length === 0) {
|
||||
throw new Error('No crash logs were created!');
|
||||
}
|
||||
|
||||
// Find the crash log for our test process
|
||||
const testCrashLog = crashLogFiles.find(file => file.includes('crash-test'));
|
||||
if (!testCrashLog) {
|
||||
throw new Error('Could not find crash log for test process');
|
||||
}
|
||||
|
||||
// Read and display crash log content
|
||||
const crashLogPath = plugins.path.join(crashLogsDir, testCrashLog);
|
||||
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
|
||||
|
||||
console.log('\n📋 Crash log content:');
|
||||
console.log('─'.repeat(60));
|
||||
console.log(crashLogContent);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
// Verify crash log contains expected information
|
||||
const checks = [
|
||||
{ text: 'CRASH REPORT', found: crashLogContent.includes('CRASH REPORT') },
|
||||
{ text: 'Exit Code: 42', found: crashLogContent.includes('Exit Code: 42') },
|
||||
{ text: 'About to crash', found: crashLogContent.includes('About to crash') },
|
||||
{ text: 'Process is running', found: crashLogContent.includes('Process is running') }
|
||||
];
|
||||
|
||||
console.log('\n✅ Verification:');
|
||||
checks.forEach(check => {
|
||||
console.log(` ${check.found ? '✓' : '✗'} Contains "${check.text}"`);
|
||||
});
|
||||
|
||||
const allChecksPassed = checks.every(c => c.found);
|
||||
|
||||
// Clean up
|
||||
console.log('\n🧹 Cleaning up...');
|
||||
execSync(`tsx ts/cli.ts delete ${processId}`, { stdio: 'inherit' });
|
||||
execSync('tsx ts/cli.ts daemon stop', { stdio: 'inherit' });
|
||||
await fs.unlink(crashScriptPath).catch(() => {});
|
||||
|
||||
if (allChecksPassed) {
|
||||
console.log('\n✅ All crash log tests passed!');
|
||||
} else {
|
||||
console.log('\n❌ Some crash log tests failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
|
||||
// Clean up on error
|
||||
try {
|
||||
execSync('tsx ts/cli.ts daemon stop', { stdio: 'inherit' });
|
||||
await fs.unlink(crashScriptPath).catch(() => {});
|
||||
} catch {}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testCrashLog();
|
172
test/test.crashlog.ts
Normal file
172
test/test.crashlog.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
// Import tspm client
|
||||
import { tspmIpcClient } from '../ts/client/tspm.ipcclient.js';
|
||||
|
||||
// Test process that will crash
|
||||
const CRASH_SCRIPT = `
|
||||
setInterval(() => {
|
||||
console.log('[test] Process is running...');
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
console.error('[test] About to crash with non-zero exit code!');
|
||||
process.exit(42);
|
||||
}, 3000);
|
||||
`;
|
||||
|
||||
tap.test('should create crash logs when process crashes', async (tools) => {
|
||||
const crashScriptPath = plugins.path.join(paths.tspmDir, 'test-crash-script.js');
|
||||
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
|
||||
|
||||
// Clean up any existing crash logs
|
||||
try {
|
||||
await fs.rm(crashLogsDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Write the crash script
|
||||
await fs.writeFile(crashScriptPath, CRASH_SCRIPT);
|
||||
|
||||
// Start the daemon
|
||||
console.log('Starting daemon...');
|
||||
const daemonResult = await tools.runCommand('tsx ts/cli/tspm.cli.ts daemon start');
|
||||
expect(daemonResult.exitCode).toEqual(0);
|
||||
|
||||
// Wait for daemon to be ready
|
||||
await tools.wait(2000);
|
||||
|
||||
// Add a process that will crash
|
||||
console.log('Adding crash test process...');
|
||||
const addResult = await tools.runCommand(`tsx ts/cli/tspm.cli.ts add "node ${crashScriptPath}" --name crash-test`);
|
||||
expect(addResult.exitCode).toEqual(0);
|
||||
|
||||
// Extract process ID from output
|
||||
const idMatch = addResult.stdout.match(/Process added with ID: (\d+)/);
|
||||
expect(idMatch).toBeTruthy();
|
||||
const processId = parseInt(idMatch![1]);
|
||||
|
||||
// Start the process
|
||||
console.log('Starting process that will crash...');
|
||||
const startResult = await tools.runCommand(`tsx ts/cli/tspm.cli.ts start ${processId}`);
|
||||
expect(startResult.exitCode).toEqual(0);
|
||||
|
||||
// Wait for the process to crash (it crashes after 3 seconds)
|
||||
console.log('Waiting for process to crash...');
|
||||
await tools.wait(5000);
|
||||
|
||||
// Check if crash log was created
|
||||
console.log('Checking for crash log...');
|
||||
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
|
||||
console.log(`Found ${crashLogFiles.length} crash log files:`, crashLogFiles);
|
||||
|
||||
// Should have at least one crash log
|
||||
expect(crashLogFiles.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the crash log for our test process
|
||||
const testCrashLog = crashLogFiles.find(file => file.includes('crash-test'));
|
||||
expect(testCrashLog).toBeTruthy();
|
||||
|
||||
// Read and verify crash log content
|
||||
const crashLogPath = plugins.path.join(crashLogsDir, testCrashLog!);
|
||||
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
|
||||
|
||||
console.log('Crash log content:');
|
||||
console.log(crashLogContent);
|
||||
|
||||
// Verify crash log contains expected information
|
||||
expect(crashLogContent).toIncludeIgnoreCase('crash report');
|
||||
expect(crashLogContent).toIncludeIgnoreCase('exit code: 42');
|
||||
expect(crashLogContent).toIncludeIgnoreCase('About to crash');
|
||||
|
||||
// Stop the process
|
||||
console.log('Cleaning up...');
|
||||
await tools.runCommand(`tsx ts/cli/tspm.cli.ts delete ${processId}`);
|
||||
|
||||
// Stop the daemon
|
||||
await tools.runCommand('tsx ts/cli/tspm.cli.ts daemon stop');
|
||||
|
||||
// Clean up test file
|
||||
await fs.unlink(crashScriptPath).catch(() => {});
|
||||
});
|
||||
|
||||
tap.test('should create crash logs when process is killed', async (tools) => {
|
||||
const killScriptPath = plugins.path.join(paths.tspmDir, 'test-kill-script.js');
|
||||
const crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
|
||||
|
||||
// Write a script that runs indefinitely
|
||||
const KILL_SCRIPT = `
|
||||
setInterval(() => {
|
||||
console.log('[test] Process is running and will be killed...');
|
||||
}, 500);
|
||||
`;
|
||||
|
||||
await fs.writeFile(killScriptPath, KILL_SCRIPT);
|
||||
|
||||
// Start the daemon
|
||||
console.log('Starting daemon...');
|
||||
const daemonResult = await tools.runCommand('tsx ts/cli/tspm.cli.ts daemon start');
|
||||
expect(daemonResult.exitCode).toEqual(0);
|
||||
|
||||
// Wait for daemon to be ready
|
||||
await tools.wait(2000);
|
||||
|
||||
// Add a process that we'll kill
|
||||
console.log('Adding kill test process...');
|
||||
const addResult = await tools.runCommand(`tsx ts/cli/tspm.cli.ts add "node ${killScriptPath}" --name kill-test`);
|
||||
expect(addResult.exitCode).toEqual(0);
|
||||
|
||||
// Extract process ID
|
||||
const idMatch = addResult.stdout.match(/Process added with ID: (\d+)/);
|
||||
expect(idMatch).toBeTruthy();
|
||||
const processId = parseInt(idMatch![1]);
|
||||
|
||||
// Start the process
|
||||
console.log('Starting process to be killed...');
|
||||
const startResult = await tools.runCommand(`tsx ts/cli/tspm.cli.ts start ${processId}`);
|
||||
expect(startResult.exitCode).toEqual(0);
|
||||
|
||||
// Wait for process to run a bit
|
||||
await tools.wait(2000);
|
||||
|
||||
// Get the actual PID of the running process
|
||||
const statusResult = await tools.runCommand(`tsx ts/cli/tspm.cli.ts describe ${processId}`);
|
||||
const pidMatch = statusResult.stdout.match(/pid:\s+(\d+)/);
|
||||
|
||||
if (pidMatch) {
|
||||
const pid = parseInt(pidMatch[1]);
|
||||
console.log(`Killing process with PID ${pid}...`);
|
||||
|
||||
// Kill the process with SIGTERM
|
||||
await tools.runCommand(`kill -TERM ${pid}`);
|
||||
|
||||
// Wait for crash log to be created
|
||||
await tools.wait(3000);
|
||||
|
||||
// Check for crash log
|
||||
console.log('Checking for crash log from killed process...');
|
||||
const crashLogFiles = await fs.readdir(crashLogsDir).catch(() => []);
|
||||
const killCrashLog = crashLogFiles.find(file => file.includes('kill-test'));
|
||||
|
||||
if (killCrashLog) {
|
||||
const crashLogPath = plugins.path.join(crashLogsDir, killCrashLog);
|
||||
const crashLogContent = await fs.readFile(crashLogPath, 'utf-8');
|
||||
|
||||
console.log('Kill crash log content:');
|
||||
console.log(crashLogContent);
|
||||
|
||||
// Verify it contains signal information
|
||||
expect(crashLogContent).toIncludeIgnoreCase('signal: SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
console.log('Cleaning up...');
|
||||
await tools.runCommand(`tsx ts/cli/tspm.cli.ts delete ${processId}`);
|
||||
await tools.runCommand('tsx ts/cli/tspm.cli.ts daemon stop');
|
||||
await fs.unlink(killScriptPath).catch(() => {});
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -5,6 +5,7 @@ import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { tspmIpcClient, TspmIpcClient } from '../ts/client/tspm.ipcclient.js';
|
||||
import { toProcessId } from '../ts/shared/protocol/id.js';
|
||||
|
||||
// Helper to ensure daemon is stopped before tests
|
||||
async function ensureDaemonStopped() {
|
||||
@@ -160,7 +161,7 @@ tap.test('Process management through daemon', async (tools) => {
|
||||
|
||||
// Test 2: Start a test process
|
||||
const testConfig: tspm.IProcessConfig = {
|
||||
id: 'test-echo',
|
||||
id: toProcessId(1001),
|
||||
name: 'Test Echo Process',
|
||||
command: 'echo "Test process"',
|
||||
projectDir: process.cwd(),
|
||||
@@ -172,7 +173,7 @@ tap.test('Process management through daemon', async (tools) => {
|
||||
config: testConfig,
|
||||
});
|
||||
console.log('Start response:', startResponse);
|
||||
expect(startResponse.processId).toEqual('test-echo');
|
||||
expect(startResponse.processId).toEqual(1001);
|
||||
expect(startResponse.status).toBeDefined();
|
||||
|
||||
// Test 3: List processes (should have one process)
|
||||
@@ -180,27 +181,27 @@ tap.test('Process management through daemon', async (tools) => {
|
||||
console.log('List after start:', listResponse);
|
||||
expect(listResponse.processes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const procInfo = listResponse.processes.find((p) => p.id === 'test-echo');
|
||||
const procInfo = listResponse.processes.find((p) => p.id === toProcessId(1001));
|
||||
expect(procInfo).toBeDefined();
|
||||
expect(procInfo?.id).toEqual('test-echo');
|
||||
expect(procInfo?.id).toEqual(1001);
|
||||
|
||||
// Test 4: Describe the process
|
||||
const describeResponse = await tspmIpcClient.request('describe', {
|
||||
id: 'test-echo',
|
||||
id: toProcessId(1001),
|
||||
});
|
||||
console.log('Describe:', describeResponse);
|
||||
expect(describeResponse.processInfo).toBeDefined();
|
||||
expect(describeResponse.config).toBeDefined();
|
||||
expect(describeResponse.config.id).toEqual('test-echo');
|
||||
expect(describeResponse.config.id).toEqual(1001);
|
||||
|
||||
// Test 5: Stop the process
|
||||
const stopResponse = await tspmIpcClient.request('stop', { id: 'test-echo' });
|
||||
const stopResponse = await tspmIpcClient.request('stop', { id: toProcessId(1001) });
|
||||
console.log('Stop response:', stopResponse);
|
||||
expect(stopResponse.success).toEqual(true);
|
||||
|
||||
// Test 6: Delete the process
|
||||
const deleteResponse = await tspmIpcClient.request('delete', {
|
||||
id: 'test-echo',
|
||||
id: toProcessId(1001),
|
||||
});
|
||||
console.log('Delete response:', deleteResponse);
|
||||
expect(deleteResponse.success).toEqual(true);
|
||||
@@ -208,9 +209,7 @@ tap.test('Process management through daemon', async (tools) => {
|
||||
// Test 7: Verify process is gone
|
||||
listResponse = await tspmIpcClient.request('list', {});
|
||||
console.log('List after delete:', listResponse);
|
||||
const deletedProcess = listResponse.processes.find(
|
||||
(p) => p.id === 'test-echo',
|
||||
);
|
||||
const deletedProcess = listResponse.processes.find((p) => p.id === toProcessId(1001));
|
||||
expect(deletedProcess).toBeUndefined();
|
||||
|
||||
// Cleanup: stop daemon
|
||||
@@ -241,7 +240,7 @@ tap.test('Batch operations through daemon', async (tools) => {
|
||||
// Add multiple test processes
|
||||
const testConfigs: tspm.IProcessConfig[] = [
|
||||
{
|
||||
id: 'batch-test-1',
|
||||
id: toProcessId(1101),
|
||||
name: 'Batch Test 1',
|
||||
command: 'echo "Process 1"',
|
||||
projectDir: process.cwd(),
|
||||
@@ -249,7 +248,7 @@ tap.test('Batch operations through daemon', async (tools) => {
|
||||
autorestart: false,
|
||||
},
|
||||
{
|
||||
id: 'batch-test-2',
|
||||
id: toProcessId(1102),
|
||||
name: 'Batch Test 2',
|
||||
command: 'echo "Process 2"',
|
||||
projectDir: process.cwd(),
|
||||
@@ -308,7 +307,7 @@ tap.test('Daemon error handling', async (tools) => {
|
||||
|
||||
// Test 1: Try to stop non-existent process
|
||||
try {
|
||||
await tspmIpcClient.request('stop', { id: 'non-existent-process' });
|
||||
await tspmIpcClient.request('stop', { id: toProcessId(99999) });
|
||||
expect(false).toEqual(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('Failed to stop process');
|
||||
@@ -316,7 +315,7 @@ tap.test('Daemon error handling', async (tools) => {
|
||||
|
||||
// Test 2: Try to describe non-existent process
|
||||
try {
|
||||
await tspmIpcClient.request('describe', { id: 'non-existent-process' });
|
||||
await tspmIpcClient.request('describe', { id: toProcessId(99999) });
|
||||
expect(false).toEqual(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('not found');
|
||||
@@ -324,7 +323,7 @@ tap.test('Daemon error handling', async (tools) => {
|
||||
|
||||
// Test 3: Try to restart non-existent process
|
||||
try {
|
||||
await tspmIpcClient.request('restart', { id: 'non-existent-process' });
|
||||
await tspmIpcClient.request('restart', { id: toProcessId(99999) });
|
||||
expect(false).toEqual(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toInclude('Failed to restart process');
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as tspm from '../ts/index.js';
|
||||
import { toProcessId } from '../ts/shared/protocol/id.js';
|
||||
import { join } from 'path';
|
||||
|
||||
// Basic module import test
|
||||
@@ -51,7 +52,7 @@ async function exampleUsingIpcClient() {
|
||||
// Start a process using the request method
|
||||
await client.request('start', {
|
||||
config: {
|
||||
id: 'web-server',
|
||||
id: toProcessId(2001),
|
||||
name: 'Web Server',
|
||||
projectDir: '/path/to/web/project',
|
||||
command: 'npm run serve',
|
||||
@@ -65,7 +66,7 @@ async function exampleUsingIpcClient() {
|
||||
// Start another process
|
||||
await client.request('start', {
|
||||
config: {
|
||||
id: 'api-server',
|
||||
id: toProcessId(2002),
|
||||
name: 'API Server',
|
||||
projectDir: '/path/to/api/project',
|
||||
command: 'npm run api',
|
||||
@@ -80,13 +81,13 @@ async function exampleUsingIpcClient() {
|
||||
|
||||
// Get logs from a process
|
||||
const logs = await client.request('getLogs', {
|
||||
id: 'web-server',
|
||||
id: toProcessId(2001),
|
||||
lines: 20,
|
||||
});
|
||||
console.log('Web server logs:', logs.logs);
|
||||
|
||||
// Stop a process
|
||||
await client.request('stop', { id: 'api-server' });
|
||||
await client.request('stop', { id: toProcessId(2002) });
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
|
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@git.zone/tspm',
|
||||
version: '5.3.0',
|
||||
version: '5.10.2',
|
||||
description: 'a no fuzz process manager'
|
||||
}
|
||||
|
@@ -39,6 +39,7 @@ export function registerDefaultCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
);
|
||||
console.log(' daemon stop Stop the daemon');
|
||||
console.log(' daemon status Show daemon status');
|
||||
console.log(' stats Show daemon + process stats');
|
||||
console.log(
|
||||
'\nUse tspm [command] --help for more information about a command.',
|
||||
);
|
||||
|
@@ -20,6 +20,7 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
console.log(' --watch Watch for file changes');
|
||||
console.log(' --watch-paths <paths> Comma-separated paths');
|
||||
console.log(' --autorestart Auto-restart on crash (default true)');
|
||||
console.log(' -i, --interactive Enter interactive edit mode after adding');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,6 +30,9 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
? parseMemoryString(argvArg.memory)
|
||||
: 512 * 1024 * 1024;
|
||||
|
||||
// Check for interactive flag
|
||||
const isInteractive = argvArg.i || argvArg.interactive;
|
||||
|
||||
// Resolve .ts single-file execution via tsx if needed
|
||||
const parts = script.split(' ');
|
||||
const first = parts[0];
|
||||
@@ -112,6 +116,12 @@ export function registerAddCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
|
||||
console.log('✓ Added');
|
||||
console.log(` Assigned ID: ${response.id}`);
|
||||
|
||||
// If interactive flag is set, enter edit mode
|
||||
if (isInteractive) {
|
||||
const { interactiveEditProcess } = await import('../../helpers/interactive-edit.js');
|
||||
await interactiveEditProcess(response.id);
|
||||
}
|
||||
},
|
||||
{ actionLabel: 'add process config' },
|
||||
);
|
||||
|
@@ -16,58 +16,12 @@ export function registerEditCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve and load current config
|
||||
// Resolve the target to get the process ID
|
||||
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
|
||||
const { config } = await tspmIpcClient.request('describe', { id: resolved.id });
|
||||
|
||||
// Interactive editing is temporarily disabled - needs smartinteract API update
|
||||
console.log('Interactive editing is temporarily disabled.');
|
||||
console.log('Current configuration:');
|
||||
console.log(` Name: ${config.name}`);
|
||||
console.log(` Command: ${config.command}`);
|
||||
console.log(` Directory: ${config.projectDir}`);
|
||||
console.log(` Memory: ${formatMemory(config.memoryLimitBytes)}`);
|
||||
console.log(` Auto-restart: ${config.autorestart}`);
|
||||
console.log(` Watch: ${config.watch ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// For now, just update environment variables to current
|
||||
const essentialEnvVars: NodeJS.ProcessEnv = {
|
||||
PATH: process.env.PATH || '',
|
||||
HOME: process.env.HOME,
|
||||
USER: process.env.USER,
|
||||
SHELL: process.env.SHELL,
|
||||
LANG: process.env.LANG,
|
||||
LC_ALL: process.env.LC_ALL,
|
||||
// Node.js specific
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
// npm/pnpm/yarn paths
|
||||
npm_config_prefix: process.env.npm_config_prefix,
|
||||
// Include any TSPM_ prefixed vars
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => key.startsWith('TSPM_'))
|
||||
),
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(essentialEnvVars).forEach(key => {
|
||||
if (essentialEnvVars[key] === undefined) {
|
||||
delete essentialEnvVars[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Update environment variables
|
||||
const updates = {
|
||||
env: { ...(config.env || {}), ...essentialEnvVars }
|
||||
};
|
||||
|
||||
const updateResponse = await tspmIpcClient.request('update', {
|
||||
id: resolved.id,
|
||||
updates,
|
||||
});
|
||||
|
||||
console.log('✓ Environment variables updated');
|
||||
console.log(' Process configuration updated successfully');
|
||||
// Use the shared interactive edit function
|
||||
const { interactiveEditProcess } = await import('../../helpers/interactive-edit.js');
|
||||
await interactiveEditProcess(resolved.id);
|
||||
},
|
||||
{ actionLabel: 'edit process config' },
|
||||
);
|
||||
|
@@ -14,19 +14,21 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
const processes = response.processes;
|
||||
|
||||
if (processes.length === 0) {
|
||||
console.log('No processes running.');
|
||||
console.log('No processes configured.');
|
||||
console.log('Use "tspm add <command>" to add one, e.g.:');
|
||||
console.log(' tspm add "pnpm start"');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Process List:');
|
||||
console.log(
|
||||
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┐',
|
||||
'┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐',
|
||||
);
|
||||
console.log(
|
||||
'│ ID │ Name │ Status │ PID │ Memory │ Restarts │',
|
||||
'│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │',
|
||||
);
|
||||
console.log(
|
||||
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┤',
|
||||
'├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤',
|
||||
);
|
||||
|
||||
for (const proc of processes) {
|
||||
@@ -38,13 +40,18 @@ export function registerListCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
: '\x1b[33m';
|
||||
const resetColor = '\x1b[0m';
|
||||
|
||||
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu)
|
||||
? `${proc.cpu.toFixed(1)}%`
|
||||
: '-';
|
||||
// Name is not part of IProcessInfo; show ID as placeholder for now
|
||||
const nameDisplay = String(proc.id);
|
||||
console.log(
|
||||
`│ ${pad(String(proc.id), 7)} │ ${pad(String(proc.id), 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||
`│ ${pad(String(proc.id), 7)} │ ${pad(nameDisplay, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(cpuStr, 8)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┘',
|
||||
'└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘',
|
||||
);
|
||||
},
|
||||
{ actionLabel: 'list processes' },
|
||||
|
@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import { tspmIpcClient } from '../../../client/tspm.ipcclient.js';
|
||||
import type { CliArguments } from '../../types.js';
|
||||
import { registerIpcCommand } from '../../registration/index.js';
|
||||
import { getBool, getNumber } from '../../helpers/argv.js';
|
||||
import { getBool, getNumber, getString } from '../../helpers/argv.js';
|
||||
import { formatLog } from '../../helpers/formatting.js';
|
||||
import { withStreamingLifecycle } from '../../helpers/lifecycle.js';
|
||||
|
||||
@@ -16,23 +16,92 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
console.error('Error: Please provide a process target');
|
||||
console.log('Usage: tspm logs <id | id:N | name:LABEL> [options]');
|
||||
console.log('\nOptions:');
|
||||
console.log(' --lines <n> Number of lines to show (default: 50)');
|
||||
console.log(' --follow Stream logs in real-time (like tail -f)');
|
||||
console.log(' --lines <n> Number of lines to show (default: 50)');
|
||||
console.log(' --since <dur> Only show logs since duration (e.g., 10m, 2h, 1d)');
|
||||
console.log(' --stderr-only Only show stderr logs');
|
||||
console.log(' --stdout-only Only show stdout logs');
|
||||
console.log(' --ndjson Output each log as JSON line');
|
||||
console.log(' --follow Stream logs in real-time (like tail -f)');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = getNumber(argvArg, 'lines', 50);
|
||||
const follow = getBool(argvArg, 'follow', 'f');
|
||||
const sinceSpec = getString(argvArg, 'since');
|
||||
const stderrOnly = getBool(argvArg, 'stderr-only');
|
||||
const stdoutOnly = getBool(argvArg, 'stdout-only');
|
||||
const ndjson = getBool(argvArg, 'ndjson');
|
||||
|
||||
const parseDuration = (spec?: string): number | undefined => {
|
||||
if (!spec) return undefined;
|
||||
const m = spec.trim().match(/^(\d+)(ms|s|m|h|d)?$/i);
|
||||
if (!m) return undefined;
|
||||
const val = Number(m[1]);
|
||||
const unit = (m[2] || 'm').toLowerCase();
|
||||
const mult = unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
|
||||
return Date.now() - val * mult;
|
||||
};
|
||||
const sinceTime = parseDuration(sinceSpec);
|
||||
const typesFilter: Array<'stdout' | 'stderr' | 'system'> | undefined =
|
||||
stderrOnly && !stdoutOnly
|
||||
? ['stderr']
|
||||
: stdoutOnly && !stderrOnly
|
||||
? ['stdout']
|
||||
: undefined; // all
|
||||
|
||||
const resolved = await tspmIpcClient.request('resolveTarget', { target: String(target) });
|
||||
const id = resolved.id;
|
||||
const response = await tspmIpcClient.request('getLogs', { id, lines });
|
||||
const response = await tspmIpcClient.request('getLogs', { id, lines: sinceTime ? 0 : lines });
|
||||
|
||||
if (!follow) {
|
||||
// One-shot mode - auto-disconnect handled by registerIpcCommand
|
||||
console.log(`Logs for process: ${id} (last ${lines} lines)`);
|
||||
const filtered = response.logs.filter((l) => {
|
||||
if (typesFilter && !typesFilter.includes(l.type)) return false;
|
||||
if (sinceTime && new Date(l.timestamp).getTime() < sinceTime) return false;
|
||||
return true;
|
||||
});
|
||||
console.log(`Logs for process: ${id} (${sinceTime ? 'since ' + new Date(sinceTime).toLocaleString() : 'last ' + lines + ' lines'})`);
|
||||
console.log('─'.repeat(60));
|
||||
for (const log of response.logs) {
|
||||
for (const log of filtered) {
|
||||
if (ndjson) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...log,
|
||||
timestamp: new Date(log.timestamp).getTime(),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const prefix =
|
||||
log.type === 'stdout'
|
||||
? '[OUT]'
|
||||
: log.type === 'stderr'
|
||||
? '[ERR]'
|
||||
: '[SYS]';
|
||||
console.log(`${timestamp} ${prefix} ${log.message}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming mode
|
||||
console.log(`Logs for process: ${resolved.name || id} (streaming...)`);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
// Prepare backlog printing state and stream handler
|
||||
let lastSeq = 0;
|
||||
let lastRunId: string | undefined = undefined;
|
||||
const printLog = (log: any) => {
|
||||
if (typesFilter && !typesFilter.includes(log.type)) return;
|
||||
if (sinceTime && new Date(log.timestamp).getTime() < sinceTime) return;
|
||||
if (ndjson) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
...log,
|
||||
timestamp: new Date(log.timestamp).getTime(),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const prefix =
|
||||
log.type === 'stdout'
|
||||
@@ -42,43 +111,60 @@ export function registerLogsCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
: '[SYS]';
|
||||
console.log(`${timestamp} ${prefix} ${log.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Streaming mode
|
||||
console.log(`Logs for process: ${resolved.name || id} (streaming...)`);
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
let lastSeq = 0;
|
||||
// Print initial backlog (already fetched via getLogs)
|
||||
for (const log of response.logs) {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const prefix =
|
||||
log.type === 'stdout'
|
||||
? '[OUT]'
|
||||
: log.type === 'stderr'
|
||||
? '[ERR]'
|
||||
: '[SYS]';
|
||||
console.log(`${timestamp} ${prefix} ${log.message}`);
|
||||
printLog(log);
|
||||
if (log.seq !== undefined) lastSeq = Math.max(lastSeq, log.seq);
|
||||
if ((log as any).runId) lastRunId = (log as any).runId;
|
||||
}
|
||||
|
||||
// Request additional backlog delivered as incremental messages to avoid large payloads
|
||||
try {
|
||||
const disposeBacklog = tspmIpcClient.onBacklogTopic(id, (log: any) => {
|
||||
if (log.runId && log.runId !== lastRunId) {
|
||||
console.log(`[INFO] Detected process restart (runId changed).`);
|
||||
lastSeq = -1;
|
||||
lastRunId = log.runId;
|
||||
}
|
||||
if (log.seq !== undefined && log.seq <= lastSeq) return;
|
||||
if (log.seq !== undefined && log.seq > lastSeq + 1) {
|
||||
console.log(
|
||||
`[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`,
|
||||
);
|
||||
}
|
||||
printLog({ ...log, timestamp: new Date(log.timestamp) });
|
||||
if (log.seq !== undefined) lastSeq = log.seq;
|
||||
});
|
||||
await tspmIpcClient.requestLogsBacklogStream(id, { lines: sinceTime ? undefined : lines, sinceTime, types: typesFilter });
|
||||
// Dispose backlog handler after a short grace (backlog is finite)
|
||||
setTimeout(() => disposeBacklog(), 10000);
|
||||
} catch {}
|
||||
|
||||
await withStreamingLifecycle(
|
||||
async () => {
|
||||
// Optional: debug subscribers if requested via env (hidden)
|
||||
if (process.env.TSPM_DEBUG === 'true') {
|
||||
try {
|
||||
const subInfo = await tspmIpcClient.request('logs:subscribers' as any, { id });
|
||||
console.log(`[DEBUG] Subscribers for logs.${id}: ${subInfo.count} (${(subInfo.subscribers||[]).join(',')})`);
|
||||
} catch {}
|
||||
}
|
||||
await tspmIpcClient.subscribe(id, (log: any) => {
|
||||
// Reset sequence if runId changed (e.g., process restarted)
|
||||
if (log.runId && log.runId !== lastRunId) {
|
||||
console.log(`[INFO] Detected process restart (runId changed).`);
|
||||
lastSeq = -1;
|
||||
lastRunId = log.runId;
|
||||
}
|
||||
if (log.seq !== undefined && log.seq <= lastSeq) return;
|
||||
if (log.seq !== undefined && log.seq > lastSeq + 1) {
|
||||
console.log(
|
||||
`[WARNING] Log gap detected: expected seq ${lastSeq + 1}, got ${log.seq}`,
|
||||
);
|
||||
}
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const prefix =
|
||||
log.type === 'stdout'
|
||||
? '[OUT]'
|
||||
: log.type === 'stderr'
|
||||
? '[ERR]'
|
||||
: '[SYS]';
|
||||
console.log(`${timestamp} ${prefix} ${log.message}`);
|
||||
printLog(log);
|
||||
if (log.seq !== undefined) lastSeq = log.seq;
|
||||
});
|
||||
},
|
||||
|
66
ts/cli/commands/stats.ts
Normal file
66
ts/cli/commands/stats.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
|
||||
import type { CliArguments } from '../types.js';
|
||||
import { registerIpcCommand } from '../registration/index.js';
|
||||
import { pad } from '../helpers/formatting.js';
|
||||
import { formatMemory } from '../helpers/memory.js';
|
||||
|
||||
export function registerStatsCommand(smartcli: plugins.smartcli.Smartcli) {
|
||||
registerIpcCommand(
|
||||
smartcli,
|
||||
'stats',
|
||||
async (_argvArg: CliArguments) => {
|
||||
// Daemon status
|
||||
const status = await tspmIpcClient.request('daemon:status', {});
|
||||
|
||||
console.log('TSPM Daemon:');
|
||||
console.log('─'.repeat(60));
|
||||
console.log(`Version: ${status.version || 'unknown'}`);
|
||||
console.log(`PID: ${status.pid}`);
|
||||
console.log(`Uptime: ${Math.floor((status.uptime || 0) / 1000)}s`);
|
||||
console.log(`Processes: ${status.processCount}`);
|
||||
if (typeof status.memoryUsage === 'number') {
|
||||
console.log(`Memory: ${formatMemory(status.memoryUsage)}`);
|
||||
}
|
||||
if (typeof status.cpuUsage === 'number') {
|
||||
console.log(`CPU (user): ${status.cpuUsage.toFixed(3)}s`);
|
||||
}
|
||||
if ((status as any).paths) {
|
||||
const pathsInfo = (status as any).paths as { tspmDir?: string; socketPath?: string; pidFile?: string };
|
||||
console.log(`tspmDir: ${pathsInfo.tspmDir || '-'}`);
|
||||
console.log(`Socket: ${pathsInfo.socketPath || '-'}`);
|
||||
console.log(`PID File: ${pathsInfo.pidFile || '-'}`);
|
||||
}
|
||||
if ((status as any).configs) {
|
||||
const cfg = (status as any).configs as { processConfigs?: number };
|
||||
console.log(`Configs: ${cfg.processConfigs ?? 0}`);
|
||||
}
|
||||
if ((status as any).logsInMemory) {
|
||||
const lm = (status as any).logsInMemory as { totalCount: number; totalBytes: number };
|
||||
console.log(`Logs (mem): ${lm.totalCount} entries, ${formatMemory(lm.totalBytes)}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Process list (reuse list view with CPU column)
|
||||
const response = await tspmIpcClient.request('list', {});
|
||||
const processes = response.processes;
|
||||
console.log('Process List:');
|
||||
console.log('┌─────────┬─────────────┬───────────┬───────────┬──────────┬──────────┬─────────┐');
|
||||
console.log('│ ID │ Name │ Status │ PID │ Memory │ CPU │ Restarts │');
|
||||
console.log('├─────────┼─────────────┼───────────┼───────────┼──────────┼──────────┼──────────┤');
|
||||
for (const proc of processes) {
|
||||
const statusColor =
|
||||
proc.status === 'online' ? '\x1b[32m' : proc.status === 'errored' ? '\x1b[31m' : '\x1b[33m';
|
||||
const resetColor = '\x1b[0m';
|
||||
const cpuStr = typeof proc.cpu === 'number' && isFinite(proc.cpu) ? `${proc.cpu.toFixed(1)}%` : '-';
|
||||
const nameDisplay = String(proc.id); // name not carried in IProcessInfo
|
||||
console.log(
|
||||
`│ ${pad(String(proc.id), 7)} │ ${pad(nameDisplay, 11)} │ ${statusColor}${pad(proc.status, 9)}${resetColor} │ ${pad((proc.pid || '-').toString(), 9)} │ ${pad(formatMemory(proc.memory), 8)} │ ${pad(cpuStr, 8)} │ ${pad(proc.restarts.toString(), 8)} │`,
|
||||
);
|
||||
}
|
||||
console.log('└─────────┴─────────────┴───────────┴───────────┴──────────┴──────────┴──────────┘');
|
||||
},
|
||||
{ actionLabel: 'get daemon stats' },
|
||||
);
|
||||
}
|
||||
|
164
ts/cli/helpers/interactive-edit.ts
Normal file
164
ts/cli/helpers/interactive-edit.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { tspmIpcClient } from '../../client/tspm.ipcclient.js';
|
||||
import { formatMemory, parseMemoryString } from './memory.js';
|
||||
|
||||
export async function interactiveEditProcess(processId: number): Promise<void> {
|
||||
// Load current config
|
||||
const { config } = await tspmIpcClient.request('describe', { id: processId as any });
|
||||
|
||||
// Create interactive prompts for editing
|
||||
const smartInteract = new plugins.smartinteract.SmartInteract([
|
||||
{
|
||||
name: 'name',
|
||||
type: 'input',
|
||||
message: 'Process name:',
|
||||
default: config.name,
|
||||
validate: (input: string) => {
|
||||
return input && input.trim() !== '';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'command',
|
||||
type: 'input',
|
||||
message: 'Command to execute:',
|
||||
default: config.command,
|
||||
validate: (input: string) => {
|
||||
return input && input.trim() !== '';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'projectDir',
|
||||
type: 'input',
|
||||
message: 'Working directory:',
|
||||
default: config.projectDir,
|
||||
validate: (input: string) => {
|
||||
return input && input.trim() !== '';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'memoryLimit',
|
||||
type: 'input',
|
||||
message: 'Memory limit (e.g., 512M, 1G):',
|
||||
default: formatMemory(config.memoryLimitBytes),
|
||||
validate: (input: string) => {
|
||||
const parsed = parseMemoryString(input);
|
||||
return parsed !== null;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'autorestart',
|
||||
type: 'confirm',
|
||||
message: 'Enable auto-restart on failure?',
|
||||
default: config.autorestart
|
||||
},
|
||||
{
|
||||
name: 'watch',
|
||||
type: 'confirm',
|
||||
message: 'Enable file watching for auto-restart?',
|
||||
default: config.watch || false
|
||||
},
|
||||
{
|
||||
name: 'updateEnv',
|
||||
type: 'confirm',
|
||||
message: 'Update environment variables to current environment?',
|
||||
default: true
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('\n📝 Edit Process Configuration');
|
||||
console.log(` Process ID: ${processId}`);
|
||||
console.log(' (Press Enter to keep current values)\n');
|
||||
|
||||
// Run the interactive prompts
|
||||
const answerBucket = await smartInteract.runQueue();
|
||||
|
||||
// Get answers from the bucket
|
||||
const name = answerBucket.getAnswerFor('name');
|
||||
const command = answerBucket.getAnswerFor('command');
|
||||
const projectDir = answerBucket.getAnswerFor('projectDir');
|
||||
const memoryLimit = answerBucket.getAnswerFor('memoryLimit');
|
||||
const autorestart = answerBucket.getAnswerFor('autorestart');
|
||||
const watch = answerBucket.getAnswerFor('watch');
|
||||
const updateEnv = answerBucket.getAnswerFor('updateEnv');
|
||||
|
||||
// Prepare updates object
|
||||
const updates: any = {};
|
||||
|
||||
// Check what has changed
|
||||
if (name !== config.name) {
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
if (command !== config.command) {
|
||||
updates.command = command;
|
||||
}
|
||||
|
||||
if (projectDir !== config.projectDir) {
|
||||
updates.projectDir = projectDir;
|
||||
}
|
||||
|
||||
const newMemoryBytes = parseMemoryString(memoryLimit);
|
||||
if (newMemoryBytes !== config.memoryLimitBytes) {
|
||||
updates.memoryLimitBytes = newMemoryBytes;
|
||||
}
|
||||
|
||||
if (autorestart !== config.autorestart) {
|
||||
updates.autorestart = autorestart;
|
||||
}
|
||||
|
||||
if (watch !== config.watch) {
|
||||
updates.watch = watch;
|
||||
}
|
||||
|
||||
// Handle environment variables update if requested
|
||||
if (updateEnv) {
|
||||
const essentialEnvVars: NodeJS.ProcessEnv = {
|
||||
PATH: process.env.PATH || '',
|
||||
HOME: process.env.HOME,
|
||||
USER: process.env.USER,
|
||||
SHELL: process.env.SHELL,
|
||||
LANG: process.env.LANG,
|
||||
LC_ALL: process.env.LC_ALL,
|
||||
// Node.js specific
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
// npm/pnpm/yarn paths
|
||||
npm_config_prefix: process.env.npm_config_prefix,
|
||||
// Include any TSPM_ prefixed vars
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => key.startsWith('TSPM_'))
|
||||
),
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(essentialEnvVars).forEach(key => {
|
||||
if (essentialEnvVars[key] === undefined) {
|
||||
delete essentialEnvVars[key];
|
||||
}
|
||||
});
|
||||
|
||||
updates.env = { ...(config.env || {}), ...essentialEnvVars };
|
||||
}
|
||||
|
||||
// Only update if there are changes
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.log('\n✓ No changes made');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send updates to daemon
|
||||
await tspmIpcClient.request('update', {
|
||||
id: processId as any,
|
||||
updates,
|
||||
});
|
||||
|
||||
// Display what was updated
|
||||
console.log('\n✓ Process configuration updated successfully');
|
||||
if (updates.name) console.log(` Name: ${updates.name}`);
|
||||
if (updates.command) console.log(` Command: ${updates.command}`);
|
||||
if (updates.projectDir) console.log(` Directory: ${updates.projectDir}`);
|
||||
if (updates.memoryLimitBytes) console.log(` Memory limit: ${formatMemory(updates.memoryLimitBytes)}`);
|
||||
if (updates.autorestart !== undefined) console.log(` Auto-restart: ${updates.autorestart}`);
|
||||
if (updates.watch !== undefined) console.log(` Watch: ${updates.watch ? 'enabled' : 'disabled'}`);
|
||||
if (updateEnv) console.log(' Environment variables: updated');
|
||||
}
|
@@ -2,6 +2,7 @@ import * as plugins from './plugins.js';
|
||||
import { tspmIpcClient } from '../client/tspm.ipcclient.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { Logger, LogLevel } from '../shared/common/utils.errorhandler.js';
|
||||
import { TspmServiceManager } from '../client/tspm.servicemanager.js';
|
||||
|
||||
// Import command registration functions
|
||||
import { registerDefaultCommand } from './commands/default.js';
|
||||
@@ -19,6 +20,7 @@ import { registerStartAllCommand } from './commands/batch/start-all.js';
|
||||
import { registerStopAllCommand } from './commands/batch/stop-all.js';
|
||||
import { registerRestartAllCommand } from './commands/batch/restart-all.js';
|
||||
import { registerDaemonCommand } from './commands/daemon/index.js';
|
||||
import { registerStatsCommand } from './commands/stats.js';
|
||||
import { registerEnableCommand } from './commands/service/enable.js';
|
||||
import { registerDisableCommand } from './commands/service/disable.js';
|
||||
import { registerResetCommand } from './commands/reset.js';
|
||||
@@ -51,6 +53,38 @@ export const run = async (): Promise<void> => {
|
||||
console.log(
|
||||
`Daemon: running v${status.version || 'unknown'} (pid ${status.pid})`,
|
||||
);
|
||||
// If versions mismatch, offer to refresh the systemd service
|
||||
if (status.version && status.version !== cliVersion) {
|
||||
console.log('\nVersion mismatch detected:');
|
||||
console.log(` CLI: v${cliVersion}`);
|
||||
console.log(` Daemon: v${status.version}`);
|
||||
console.log(
|
||||
'\nThis can happen after upgrading tspm. The systemd service may still point to an older version.\n' +
|
||||
'You can refresh the service (equivalent to "tspm disable" then "tspm enable").',
|
||||
);
|
||||
|
||||
// Ask the user for confirmation
|
||||
const confirm = await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
||||
'Refresh the systemd service now?',
|
||||
true,
|
||||
);
|
||||
if (confirm) {
|
||||
try {
|
||||
const sm = new TspmServiceManager();
|
||||
console.log('Refreshing TSPM system service...');
|
||||
await sm.disableService();
|
||||
await sm.enableService();
|
||||
console.log('✓ Service refreshed. Daemon restarted via systemd.');
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
'Failed to refresh service automatically. You can try manually:\n tspm disable && tspm enable',
|
||||
);
|
||||
console.error(err?.message || String(err));
|
||||
}
|
||||
} else {
|
||||
console.log('Skipped service refresh.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('Daemon: not running');
|
||||
}
|
||||
@@ -84,6 +118,7 @@ export const run = async (): Promise<void> => {
|
||||
|
||||
// Daemon commands
|
||||
registerDaemonCommand(smartcliInstance);
|
||||
registerStatsCommand(smartcliInstance);
|
||||
|
||||
// Service commands
|
||||
registerEnableCommand(smartcliInstance);
|
||||
|
@@ -1,6 +1,7 @@
|
||||
// Minimal plugin set for lightweight client startup
|
||||
import * as path from 'node:path';
|
||||
import * as smartdaemon from '@push.rocks/smartdaemon';
|
||||
import * as smartipc from '@push.rocks/smartipc';
|
||||
|
||||
export { path, smartipc };
|
||||
export { path, smartdaemon, smartipc };
|
||||
|
||||
|
@@ -17,6 +17,9 @@ export class TspmIpcClient {
|
||||
private socketPath: string;
|
||||
private daemonPidFile: string;
|
||||
private isConnected: boolean = false;
|
||||
// Store event handlers for cleanup
|
||||
private heartbeatTimeoutHandler?: () => void;
|
||||
private markDisconnectedHandler?: () => void;
|
||||
|
||||
constructor() {
|
||||
this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock');
|
||||
@@ -74,20 +77,21 @@ export class TspmIpcClient {
|
||||
this.isConnected = true;
|
||||
|
||||
// Handle heartbeat timeouts gracefully
|
||||
this.ipcClient.on('heartbeatTimeout', () => {
|
||||
this.heartbeatTimeoutHandler = () => {
|
||||
console.warn('Heartbeat timeout detected, connection may be degraded');
|
||||
this.isConnected = false;
|
||||
});
|
||||
};
|
||||
this.ipcClient.on('heartbeatTimeout', this.heartbeatTimeoutHandler);
|
||||
|
||||
// Reflect connection lifecycle on the client state
|
||||
const markDisconnected = () => {
|
||||
this.markDisconnectedHandler = () => {
|
||||
this.isConnected = false;
|
||||
};
|
||||
// Common lifecycle events
|
||||
this.ipcClient.on('disconnect', markDisconnected as any);
|
||||
this.ipcClient.on('close', markDisconnected as any);
|
||||
this.ipcClient.on('end', markDisconnected as any);
|
||||
this.ipcClient.on('error', markDisconnected as any);
|
||||
this.ipcClient.on('disconnect', this.markDisconnectedHandler as any);
|
||||
this.ipcClient.on('close', this.markDisconnectedHandler as any);
|
||||
this.ipcClient.on('end', this.markDisconnectedHandler as any);
|
||||
this.ipcClient.on('error', this.markDisconnectedHandler as any);
|
||||
|
||||
// connected
|
||||
} catch (error) {
|
||||
@@ -103,6 +107,21 @@ export class TspmIpcClient {
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.ipcClient) {
|
||||
// Remove event listeners before disconnecting
|
||||
if (this.heartbeatTimeoutHandler) {
|
||||
this.ipcClient.removeListener('heartbeatTimeout', this.heartbeatTimeoutHandler);
|
||||
}
|
||||
if (this.markDisconnectedHandler) {
|
||||
this.ipcClient.removeListener('disconnect', this.markDisconnectedHandler as any);
|
||||
this.ipcClient.removeListener('close', this.markDisconnectedHandler as any);
|
||||
this.ipcClient.removeListener('end', this.markDisconnectedHandler as any);
|
||||
this.ipcClient.removeListener('error', this.markDisconnectedHandler as any);
|
||||
}
|
||||
|
||||
// Clear handler references
|
||||
this.heartbeatTimeoutHandler = undefined;
|
||||
this.markDisconnectedHandler = undefined;
|
||||
|
||||
await this.ipcClient.disconnect();
|
||||
this.ipcClient = null;
|
||||
this.isConnected = false;
|
||||
@@ -155,7 +174,58 @@ export class TspmIpcClient {
|
||||
|
||||
const id = toProcessId(processId);
|
||||
const topic = `logs.${id}`;
|
||||
await this.ipcClient.subscribe(`topic:${topic}`, handler);
|
||||
// Note: IpcClient.subscribe expects the bare topic (without the 'topic:' prefix)
|
||||
// and will register a handler for 'topic:<topic>' internally.
|
||||
await this.ipcClient.subscribe(topic, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request backlog logs as a stream from the daemon.
|
||||
* The actual stream will be delivered via the 'stream' event.
|
||||
*/
|
||||
public async requestLogsBacklogStream(
|
||||
processId: ProcessId | number | string,
|
||||
opts: { lines?: number; sinceTime?: number; types?: Array<'stdout' | 'stderr' | 'system'> } = {},
|
||||
): Promise<void> {
|
||||
if (!this.ipcClient || !this.isConnected) {
|
||||
throw new Error('Not connected to daemon');
|
||||
}
|
||||
const id = toProcessId(processId);
|
||||
await this.request('logs:subscribe' as any, {
|
||||
id,
|
||||
lines: opts.lines,
|
||||
sinceTime: opts.sinceTime,
|
||||
types: opts.types,
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for incoming streams (e.g., backlog logs)
|
||||
*/
|
||||
public onStream(
|
||||
handler: (info: any, readable: NodeJS.ReadableStream) => void,
|
||||
): void {
|
||||
if (!this.ipcClient) throw new Error('Not connected to daemon');
|
||||
// smartipc emits 'stream' with (info, readable)
|
||||
(this.ipcClient as any).on('stream', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a temporary handler for backlog topic messages for a specific process
|
||||
*/
|
||||
public onBacklogTopic(
|
||||
processId: ProcessId | number | string,
|
||||
handler: (log: any) => void,
|
||||
): () => void {
|
||||
if (!this.ipcClient) throw new Error('Not connected to daemon');
|
||||
const id = toProcessId(processId);
|
||||
const topicType = `topic:logs.backlog.${id}`;
|
||||
(this.ipcClient as any).onMessage(topicType, handler);
|
||||
return () => {
|
||||
try {
|
||||
(this.ipcClient as any).messageHandlers?.delete?.(topicType);
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +238,8 @@ export class TspmIpcClient {
|
||||
|
||||
const id = toProcessId(processId);
|
||||
const topic = `logs.${id}`;
|
||||
await this.ipcClient.unsubscribe(`topic:${topic}`);
|
||||
// Pass bare topic; client handles 'topic:' prefix internally
|
||||
await this.ipcClient.unsubscribe(topic);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
|
||||
/**
|
||||
|
265
ts/daemon/crashlogmanager.ts
Normal file
265
ts/daemon/crashlogmanager.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import type { IProcessLog } from '../shared/protocol/ipc.types.js';
|
||||
import type { ProcessId } from '../shared/protocol/id.js';
|
||||
|
||||
/**
|
||||
* Manages crash log storage for failed processes
|
||||
*/
|
||||
export class CrashLogManager {
|
||||
private crashLogsDir: string;
|
||||
private readonly MAX_CRASH_LOGS = 100;
|
||||
private readonly MAX_LOG_SIZE_BYTES = 1024 * 1024; // 1MB
|
||||
|
||||
constructor() {
|
||||
this.crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a crash log for a failed process
|
||||
*/
|
||||
public async saveCrashLog(
|
||||
processId: ProcessId,
|
||||
processName: string,
|
||||
logs: IProcessLog[],
|
||||
exitCode: number | null,
|
||||
signal: string | null,
|
||||
restartCount: number,
|
||||
memoryUsage?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await this.ensureCrashLogsDir();
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = new Date();
|
||||
const dateStr = this.formatDate(timestamp);
|
||||
const sanitizedName = this.sanitizeFilename(processName);
|
||||
const filename = `${dateStr}_${processId}_${sanitizedName}.log`;
|
||||
const filepath = plugins.path.join(this.crashLogsDir, filename);
|
||||
|
||||
// Get recent logs that fit within size limit
|
||||
const recentLogs = this.getRecentLogs(logs, this.MAX_LOG_SIZE_BYTES);
|
||||
|
||||
// Create crash report
|
||||
const crashReport = this.formatCrashReport({
|
||||
processId,
|
||||
processName,
|
||||
timestamp,
|
||||
exitCode,
|
||||
signal,
|
||||
restartCount,
|
||||
memoryUsage,
|
||||
logs: recentLogs
|
||||
});
|
||||
|
||||
// Write crash log
|
||||
await plugins.smartfile.memory.toFs(crashReport, filepath);
|
||||
|
||||
// Rotate old logs if needed
|
||||
await this.rotateOldLogs();
|
||||
|
||||
console.log(`Crash log saved: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save crash log for process ${processId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for filename: YYYY-MM-DD_HH-mm-ss
|
||||
*/
|
||||
private formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize process name for use in filename
|
||||
*/
|
||||
private sanitizeFilename(name: string): string {
|
||||
// Replace problematic characters with underscore
|
||||
return name
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.substring(0, 50); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs that fit within the size limit
|
||||
*/
|
||||
private getRecentLogs(logs: IProcessLog[], maxBytes: number): IProcessLog[] {
|
||||
if (logs.length === 0) return [];
|
||||
|
||||
// Start from the end and work backwards
|
||||
const recentLogs: IProcessLog[] = [];
|
||||
let currentSize = 0;
|
||||
|
||||
for (let i = logs.length - 1; i >= 0; i--) {
|
||||
const log = logs[i];
|
||||
const logSize = this.estimateLogSize(log);
|
||||
|
||||
if (currentSize + logSize > maxBytes && recentLogs.length > 0) {
|
||||
// Would exceed limit, stop adding
|
||||
break;
|
||||
}
|
||||
|
||||
recentLogs.unshift(log);
|
||||
currentSize += logSize;
|
||||
}
|
||||
|
||||
return recentLogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate size of a log entry in bytes
|
||||
*/
|
||||
private estimateLogSize(log: IProcessLog): number {
|
||||
// Format: [timestamp] [type] message\n
|
||||
const formatted = `[${new Date(log.timestamp).toISOString()}] [${log.type}] ${log.message}\n`;
|
||||
return Buffer.byteLength(formatted, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a crash report with metadata and logs
|
||||
*/
|
||||
private formatCrashReport(data: {
|
||||
processId: ProcessId;
|
||||
processName: string;
|
||||
timestamp: Date;
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
restartCount: number;
|
||||
memoryUsage?: number;
|
||||
logs: IProcessLog[];
|
||||
}): string {
|
||||
const lines: string[] = [
|
||||
'================================================================================',
|
||||
'TSPM CRASH REPORT',
|
||||
'================================================================================',
|
||||
`Process: ${data.processName} (ID: ${data.processId})`,
|
||||
`Date: ${data.timestamp.toISOString()}`,
|
||||
`Exit Code: ${data.exitCode ?? 'N/A'}`,
|
||||
`Signal: ${data.signal ?? 'N/A'}`,
|
||||
`Restart Attempt: ${data.restartCount}/10`,
|
||||
];
|
||||
|
||||
if (data.memoryUsage !== undefined && data.memoryUsage > 0) {
|
||||
lines.push(`Memory Usage: ${this.humanReadableBytes(data.memoryUsage)}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'================================================================================',
|
||||
'',
|
||||
`LAST ${data.logs.length} LOG ENTRIES:`,
|
||||
'--------------------------------------------------------------------------------',
|
||||
''
|
||||
);
|
||||
|
||||
// Add log entries
|
||||
for (const log of data.logs) {
|
||||
const timestamp = new Date(log.timestamp).toISOString();
|
||||
const type = log.type.toUpperCase().padEnd(6);
|
||||
lines.push(`[${timestamp}] [${type}] ${log.message}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'================================================================================',
|
||||
'END OF CRASH REPORT',
|
||||
'================================================================================',
|
||||
''
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to human-readable format
|
||||
*/
|
||||
private humanReadableBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure crash logs directory exists
|
||||
*/
|
||||
private async ensureCrashLogsDir(): Promise<void> {
|
||||
await plugins.smartfile.fs.ensureDir(this.crashLogsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate old crash logs when exceeding max count
|
||||
*/
|
||||
private async rotateOldLogs(): Promise<void> {
|
||||
try {
|
||||
// Get all crash log files
|
||||
const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, '*.log');
|
||||
|
||||
if (files.length <= this.MAX_CRASH_LOGS) {
|
||||
return; // No rotation needed
|
||||
}
|
||||
|
||||
// Get file stats and sort by modification time (oldest first)
|
||||
const fileStats = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const filepath = plugins.path.join(this.crashLogsDir, file);
|
||||
const stats = await plugins.smartfile.fs.stat(filepath);
|
||||
return { filepath, mtime: stats.mtime.getTime() };
|
||||
})
|
||||
);
|
||||
|
||||
fileStats.sort((a, b) => a.mtime - b.mtime);
|
||||
|
||||
// Delete oldest files to stay under limit
|
||||
const filesToDelete = fileStats.length - this.MAX_CRASH_LOGS;
|
||||
for (let i = 0; i < filesToDelete; i++) {
|
||||
await plugins.smartfile.fs.remove(fileStats[i].filepath);
|
||||
console.log(`Rotated old crash log: ${plugins.path.basename(fileStats[i].filepath)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to rotate crash logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of crash logs for a specific process
|
||||
*/
|
||||
public async getCrashLogsForProcess(processId: ProcessId): Promise<string[]> {
|
||||
try {
|
||||
await this.ensureCrashLogsDir();
|
||||
const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, `*_${processId}_*.log`);
|
||||
return files.map(file => plugins.path.join(this.crashLogsDir, file));
|
||||
} catch (error) {
|
||||
console.error(`Failed to get crash logs for process ${processId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all crash logs (for maintenance)
|
||||
*/
|
||||
public async cleanupAllCrashLogs(): Promise<void> {
|
||||
try {
|
||||
await this.ensureCrashLogsDir();
|
||||
const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, '*.log');
|
||||
|
||||
for (const file of files) {
|
||||
const filepath = plugins.path.join(this.crashLogsDir, file);
|
||||
await plugins.smartfile.fs.remove(filepath);
|
||||
}
|
||||
|
||||
console.log(`Cleaned up ${files.length} crash logs`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup crash logs:', error);
|
||||
}
|
||||
}
|
||||
}
|
@@ -95,6 +95,16 @@ export class ProcessManager extends EventEmitter {
|
||||
|
||||
// Check if process with this id already exists
|
||||
if (this.processes.has(config.id)) {
|
||||
const existing = this.processes.get(config.id)!;
|
||||
// If an existing monitor is present but not running, treat this as a fresh start via restart logic
|
||||
if (!existing.isRunning()) {
|
||||
this.logger.info(
|
||||
`Existing monitor found for id '${config.id}' but not running. Restarting it...`,
|
||||
);
|
||||
await this.restart(config.id);
|
||||
return;
|
||||
}
|
||||
// Already running – surface a meaningful error
|
||||
throw new ValidationError(
|
||||
`Process with id '${config.id}' already exists`,
|
||||
'ERR_DUPLICATE_PROCESS',
|
||||
@@ -246,7 +256,8 @@ export class ProcessManager extends EventEmitter {
|
||||
|
||||
try {
|
||||
await monitor.stop();
|
||||
this.updateProcessInfo(id, { status: 'stopped' });
|
||||
// Ensure status and PID are reflected immediately
|
||||
this.updateProcessInfo(id, { status: 'stopped', pid: undefined });
|
||||
this.logger.info(`Successfully stopped process with id '${id}'`);
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
@@ -430,6 +441,8 @@ export class ProcessManager extends EventEmitter {
|
||||
const pid = monitor.getPid();
|
||||
if (pid) {
|
||||
info.pid = pid;
|
||||
} else {
|
||||
info.pid = undefined;
|
||||
}
|
||||
|
||||
// Update uptime if available
|
||||
@@ -438,13 +451,18 @@ export class ProcessManager extends EventEmitter {
|
||||
info.uptime = uptime;
|
||||
}
|
||||
|
||||
// Update memory and cpu from latest monitor readings
|
||||
info.memory = monitor.getLastMemoryUsage();
|
||||
const cpu = monitor.getLastCpuUsage();
|
||||
if (Number.isFinite(cpu)) {
|
||||
info.cpu = cpu;
|
||||
}
|
||||
|
||||
// Update restart count
|
||||
info.restarts = monitor.getRestartCount();
|
||||
|
||||
// Update status based on actual running state
|
||||
if (monitor.isRunning()) {
|
||||
info.status = 'online';
|
||||
}
|
||||
info.status = monitor.isRunning() ? 'online' : 'stopped';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,8 +510,12 @@ export class ProcessManager extends EventEmitter {
|
||||
*/
|
||||
public async startAll(): Promise<void> {
|
||||
for (const [id, config] of this.processConfigs.entries()) {
|
||||
if (!this.processes.has(id)) {
|
||||
const monitor = this.processes.get(id);
|
||||
if (!monitor) {
|
||||
await this.start(config);
|
||||
} else if (!monitor.isRunning()) {
|
||||
// If a monitor exists but is not running, restart the process to ensure a clean start
|
||||
await this.restart(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,7 +739,8 @@ export class ProcessManager extends EventEmitter {
|
||||
throw configError;
|
||||
}
|
||||
} else {
|
||||
this.logger.info('No saved process configurations found');
|
||||
// First run / no configs yet — keep this quiet unless debugging
|
||||
this.logger.debug('No saved process configurations found');
|
||||
}
|
||||
} catch (error: Error | unknown) {
|
||||
// Only throw if it's not the "no configs found" case
|
||||
@@ -726,9 +749,7 @@ export class ProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// If no configs found or error reading, just continue with empty configs
|
||||
this.logger.info(
|
||||
'No saved process configurations found or error reading them',
|
||||
);
|
||||
this.logger.debug('No saved process configurations found or error reading them');
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProcessWrapper } from './processwrapper.js';
|
||||
import { LogPersistence } from './logpersistence.js';
|
||||
import { CrashLogManager } from './crashlogmanager.js';
|
||||
import { Logger, ProcessError, handleError } from '../shared/common/utils.errorhandler.js';
|
||||
import type { IMonitorConfig, IProcessLog } from '../shared/protocol/ipc.types.js';
|
||||
import type { ProcessId } from '../shared/protocol/id.js';
|
||||
@@ -15,13 +16,23 @@ export class ProcessMonitor extends EventEmitter {
|
||||
private logger: Logger;
|
||||
private logs: IProcessLog[] = [];
|
||||
private logPersistence: LogPersistence;
|
||||
private crashLogManager: CrashLogManager;
|
||||
private processId?: ProcessId;
|
||||
private currentLogMemorySize: number = 0;
|
||||
private readonly MAX_LOG_MEMORY_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
// Track approximate size per log to avoid O(n) JSON stringify on every update
|
||||
private logSizeMap: WeakMap<IProcessLog, number> = new WeakMap();
|
||||
private restartTimer: NodeJS.Timeout | null = null;
|
||||
private lastRetryAt: number | null = null;
|
||||
private readonly MAX_RETRIES = 10;
|
||||
private readonly RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
private lastMemoryUsage: number = 0;
|
||||
private lastCpuUsage: number = 0;
|
||||
// Store event listeners for cleanup
|
||||
private logHandler?: (log: IProcessLog) => void;
|
||||
private startHandler?: (pid: number) => void;
|
||||
private exitHandler?: (code: number | null, signal: string | null) => Promise<void>;
|
||||
private errorHandler?: (error: Error | ProcessError) => Promise<void>;
|
||||
|
||||
constructor(config: IMonitorConfig & { id?: ProcessId }) {
|
||||
super();
|
||||
@@ -29,6 +40,7 @@ export class ProcessMonitor extends EventEmitter {
|
||||
this.logger = new Logger(`ProcessMonitor:${config.name || 'unnamed'}`);
|
||||
this.logs = [];
|
||||
this.logPersistence = new LogPersistence();
|
||||
this.crashLogManager = new CrashLogManager();
|
||||
this.processId = config.id;
|
||||
this.currentLogMemorySize = 0;
|
||||
}
|
||||
@@ -39,7 +51,13 @@ export class ProcessMonitor extends EventEmitter {
|
||||
const persistedLogs = await this.logPersistence.loadLogs(this.processId);
|
||||
if (persistedLogs.length > 0) {
|
||||
this.logs = persistedLogs;
|
||||
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
|
||||
// Recalculate size once from scratch and seed the size map
|
||||
this.currentLogMemorySize = 0;
|
||||
for (const log of this.logs) {
|
||||
const size = this.estimateLogSize(log);
|
||||
this.logSizeMap.set(log, size);
|
||||
this.currentLogMemorySize += size;
|
||||
}
|
||||
this.logger.info(`Loaded ${persistedLogs.length} persisted logs from disk`);
|
||||
|
||||
// Delete the persisted file after loading
|
||||
@@ -73,6 +91,14 @@ export class ProcessMonitor extends EventEmitter {
|
||||
|
||||
this.logger.info(`Spawning process: ${this.config.command}`);
|
||||
|
||||
// Clear any orphaned pidusage cache entries before spawning
|
||||
try {
|
||||
(plugins.pidusage as any)?.clearAll?.();
|
||||
} catch {}
|
||||
|
||||
// Clean up previous listeners if any
|
||||
this.cleanupListeners();
|
||||
|
||||
// Create a new process wrapper
|
||||
this.processWrapper = new ProcessWrapper({
|
||||
name: this.config.name || 'unnamed-process',
|
||||
@@ -84,21 +110,31 @@ export class ProcessMonitor extends EventEmitter {
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
this.processWrapper.on('log', (log: IProcessLog): void => {
|
||||
this.logHandler = (log: IProcessLog): void => {
|
||||
// Store the log in our buffer
|
||||
this.logs.push(log);
|
||||
console.error(`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`);
|
||||
console.error(`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`);
|
||||
if (process.env.TSPM_DEBUG) {
|
||||
console.error(
|
||||
`[ProcessMonitor:${this.config.name}] Received log (type=${log.type}): ${log.message}`,
|
||||
);
|
||||
console.error(
|
||||
`[ProcessMonitor:${this.config.name}] Logs array now has ${this.logs.length} items`,
|
||||
);
|
||||
}
|
||||
this.logger.debug(`ProcessMonitor received log: ${log.message}`);
|
||||
|
||||
// Update memory size tracking
|
||||
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
|
||||
// Update memory size tracking incrementally
|
||||
const approxSize = this.estimateLogSize(log);
|
||||
this.logSizeMap.set(log, approxSize);
|
||||
this.currentLogMemorySize += approxSize;
|
||||
|
||||
// Trim logs if they exceed memory limit (10MB)
|
||||
while (this.currentLogMemorySize > this.MAX_LOG_MEMORY_SIZE && this.logs.length > 1) {
|
||||
// Remove oldest logs until we're under the memory limit
|
||||
this.logs.shift();
|
||||
this.currentLogMemorySize = LogPersistence.calculateLogMemorySize(this.logs);
|
||||
const removed = this.logs.shift()!;
|
||||
const removedSize = this.logSizeMap.get(removed) ?? this.estimateLogSize(removed);
|
||||
this.logSizeMap.delete(removed); // Clean up map entry to prevent memory leak
|
||||
this.currentLogMemorySize -= removedSize;
|
||||
}
|
||||
|
||||
// Re-emit the log event for upstream handlers
|
||||
@@ -108,20 +144,49 @@ export class ProcessMonitor extends EventEmitter {
|
||||
if (log.type === 'system') {
|
||||
this.log(log.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.processWrapper.on('log', this.logHandler);
|
||||
|
||||
// Re-emit start event with PID for upstream handlers
|
||||
this.processWrapper.on('start', (pid: number): void => {
|
||||
this.startHandler = (pid: number): void => {
|
||||
this.emit('start', pid);
|
||||
});
|
||||
};
|
||||
this.processWrapper.on('start', this.startHandler);
|
||||
|
||||
this.processWrapper.on(
|
||||
'exit',
|
||||
async (code: number | null, signal: string | null): Promise<void> => {
|
||||
this.exitHandler = async (code: number | null, signal: string | null): Promise<void> => {
|
||||
const exitMsg = `Process exited with code ${code}, signal ${signal}.`;
|
||||
this.logger.info(exitMsg);
|
||||
this.log(exitMsg);
|
||||
|
||||
// Clear pidusage internal state for this PID to prevent memory leaks
|
||||
try {
|
||||
const pidToClear = this.processWrapper?.getPid();
|
||||
if (pidToClear) {
|
||||
(plugins.pidusage as any)?.clear?.(pidToClear);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Detect if this was a crash (non-zero exit code or killed by signal)
|
||||
const isCrash = (code !== null && code !== 0) || signal !== null;
|
||||
|
||||
// Save crash log if this was a crash
|
||||
if (isCrash && this.processId && this.config.name) {
|
||||
try {
|
||||
await this.crashLogManager.saveCrashLog(
|
||||
this.processId,
|
||||
this.config.name,
|
||||
this.logs,
|
||||
code,
|
||||
signal,
|
||||
this.restartCount,
|
||||
this.lastMemoryUsage
|
||||
);
|
||||
this.logger.info(`Saved crash log for process ${this.config.name}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to save crash log: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush logs to disk on exit
|
||||
if (this.processId && this.logs.length > 0) {
|
||||
try {
|
||||
@@ -142,10 +207,10 @@ export class ProcessMonitor extends EventEmitter {
|
||||
'Not restarting process because monitor is stopped',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
this.processWrapper.on('exit', this.exitHandler);
|
||||
|
||||
this.processWrapper.on('error', async (error: Error | ProcessError): Promise<void> => {
|
||||
this.errorHandler = async (error: Error | ProcessError): Promise<void> => {
|
||||
const errorMsg =
|
||||
error instanceof ProcessError
|
||||
? `Process error: ${error.toString()}`
|
||||
@@ -154,6 +219,24 @@ export class ProcessMonitor extends EventEmitter {
|
||||
this.logger.error(error);
|
||||
this.log(errorMsg);
|
||||
|
||||
// Save crash log for errors
|
||||
if (this.processId && this.config.name) {
|
||||
try {
|
||||
await this.crashLogManager.saveCrashLog(
|
||||
this.processId,
|
||||
this.config.name,
|
||||
this.logs,
|
||||
null, // no exit code for errors
|
||||
null, // no signal for errors
|
||||
this.restartCount,
|
||||
this.lastMemoryUsage
|
||||
);
|
||||
this.logger.info(`Saved crash log for process ${this.config.name} due to error`);
|
||||
} catch (crashLogError) {
|
||||
this.logger.error(`Failed to save crash log: ${crashLogError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush logs to disk on error
|
||||
if (this.processId && this.logs.length > 0) {
|
||||
try {
|
||||
@@ -169,7 +252,8 @@ export class ProcessMonitor extends EventEmitter {
|
||||
} else {
|
||||
this.logger.debug('Not restarting process because monitor is stopped');
|
||||
}
|
||||
});
|
||||
};
|
||||
this.processWrapper.on('error', this.errorHandler);
|
||||
|
||||
// Start the process
|
||||
try {
|
||||
@@ -183,6 +267,31 @@ export class ProcessMonitor extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up event listeners from process wrapper
|
||||
*/
|
||||
private cleanupListeners(): void {
|
||||
if (this.processWrapper) {
|
||||
if (this.logHandler) {
|
||||
this.processWrapper.removeListener('log', this.logHandler);
|
||||
}
|
||||
if (this.startHandler) {
|
||||
this.processWrapper.removeListener('start', this.startHandler);
|
||||
}
|
||||
if (this.exitHandler) {
|
||||
this.processWrapper.removeListener('exit', this.exitHandler);
|
||||
}
|
||||
if (this.errorHandler) {
|
||||
this.processWrapper.removeListener('error', this.errorHandler);
|
||||
}
|
||||
}
|
||||
// Clear references
|
||||
this.logHandler = undefined;
|
||||
this.startHandler = undefined;
|
||||
this.exitHandler = undefined;
|
||||
this.errorHandler = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a restart with incremental debounce and failure cutoff.
|
||||
*/
|
||||
@@ -235,18 +344,24 @@ export class ProcessMonitor extends EventEmitter {
|
||||
memoryLimit: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const memoryUsage = await this.getProcessGroupMemory(pid);
|
||||
const { memory: memoryUsage, cpu: cpuUsage } = await this.getProcessGroupStats(pid);
|
||||
|
||||
this.logger.debug(
|
||||
`Memory usage for PID ${pid}: ${this.humanReadableBytes(memoryUsage)} (${memoryUsage} bytes)`,
|
||||
);
|
||||
|
||||
// Only log to the process log at longer intervals to avoid spamming
|
||||
this.log(
|
||||
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes(
|
||||
memoryUsage,
|
||||
)} (${memoryUsage} bytes)`,
|
||||
);
|
||||
// Store latest readings
|
||||
this.lastMemoryUsage = memoryUsage;
|
||||
this.lastCpuUsage = cpuUsage;
|
||||
|
||||
// Only log memory usage in debug mode to avoid spamming
|
||||
if (process.env.TSPM_DEBUG) {
|
||||
this.log(
|
||||
`Current memory usage for process group (PID ${pid}): ${this.humanReadableBytes(
|
||||
memoryUsage,
|
||||
)} (${memoryUsage} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (memoryUsage > memoryLimit) {
|
||||
const memoryLimitMsg = `Memory usage ${this.humanReadableBytes(
|
||||
@@ -258,7 +373,7 @@ export class ProcessMonitor extends EventEmitter {
|
||||
|
||||
// Stop the process wrapper, which will trigger the exit handler and restart
|
||||
if (this.processWrapper) {
|
||||
this.processWrapper.stop();
|
||||
await this.processWrapper.stop();
|
||||
}
|
||||
}
|
||||
} catch (error: Error | unknown) {
|
||||
@@ -276,7 +391,7 @@ export class ProcessMonitor extends EventEmitter {
|
||||
/**
|
||||
* Get the total memory usage (in bytes) for the process group (the main process and its children).
|
||||
*/
|
||||
private getProcessGroupMemory(pid: number): Promise<number> {
|
||||
private getProcessGroupStats(pid: number): Promise<{ memory: number; cpu: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.debug(
|
||||
`Getting memory usage for process group with PID ${pid}`,
|
||||
@@ -284,7 +399,7 @@ export class ProcessMonitor extends EventEmitter {
|
||||
|
||||
plugins.psTree(
|
||||
pid,
|
||||
(err: Error | null, children: Array<{ PID: string }>) => {
|
||||
(err: any, children: ReadonlyArray<{ PID: string }>) => {
|
||||
if (err) {
|
||||
const processError = new ProcessError(
|
||||
`Failed to get process tree: ${err.message}`,
|
||||
@@ -306,7 +421,7 @@ export class ProcessMonitor extends EventEmitter {
|
||||
|
||||
plugins.pidusage(
|
||||
pids,
|
||||
(err: Error | null, stats: Record<string, { memory: number }>) => {
|
||||
(err: Error | null, stats: Record<string, { memory: number; cpu: number }>) => {
|
||||
if (err) {
|
||||
const processError = new ProcessError(
|
||||
`Failed to get process usage stats: ${err.message}`,
|
||||
@@ -318,14 +433,22 @@ export class ProcessMonitor extends EventEmitter {
|
||||
}
|
||||
|
||||
let totalMemory = 0;
|
||||
let totalCpu = 0;
|
||||
for (const key in stats) {
|
||||
totalMemory += stats[key].memory;
|
||||
// Check if stats[key] exists and is not null (process may have exited)
|
||||
if (stats[key]) {
|
||||
totalMemory += stats[key].memory || 0;
|
||||
totalCpu += Number.isFinite(stats[key].cpu) ? stats[key].cpu : 0;
|
||||
} else {
|
||||
this.logger.debug(`Process ${key} stats are null (process may have exited)`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Total memory for process group: ${this.humanReadableBytes(totalMemory)}`,
|
||||
);
|
||||
resolve(totalMemory);
|
||||
|
||||
resolve({ memory: totalMemory, cpu: totalCpu });
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -352,6 +475,9 @@ export class ProcessMonitor extends EventEmitter {
|
||||
this.log('Stopping process monitor.');
|
||||
this.stopped = true;
|
||||
|
||||
// Clean up event listeners
|
||||
this.cleanupListeners();
|
||||
|
||||
// Flush logs to disk before stopping
|
||||
if (this.processId && this.logs.length > 0) {
|
||||
try {
|
||||
@@ -365,8 +491,20 @@ export class ProcessMonitor extends EventEmitter {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
// Cancel any pending restart timer
|
||||
if (this.restartTimer) {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
}
|
||||
if (this.processWrapper) {
|
||||
this.processWrapper.stop();
|
||||
// Clear pidusage state for current PID before stopping to avoid leaks
|
||||
try {
|
||||
const pidToClear = this.processWrapper.getPid();
|
||||
if (pidToClear) {
|
||||
(plugins.pidusage as any)?.clear?.(pidToClear);
|
||||
}
|
||||
} catch {}
|
||||
await this.processWrapper.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +512,11 @@ export class ProcessMonitor extends EventEmitter {
|
||||
* Get the current logs from the process
|
||||
*/
|
||||
public getLogs(limit?: number): IProcessLog[] {
|
||||
console.error(`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`);
|
||||
if (process.env.TSPM_DEBUG) {
|
||||
console.error(
|
||||
`[ProcessMonitor:${this.config.name}] getLogs called, logs.length=${this.logs.length}, limit=${limit}`,
|
||||
);
|
||||
}
|
||||
this.logger.debug(`Getting logs, total stored: ${this.logs.length}`);
|
||||
if (limit && limit > 0) {
|
||||
return this.logs.slice(-limit);
|
||||
@@ -410,6 +552,20 @@ export class ProcessMonitor extends EventEmitter {
|
||||
return this.processWrapper?.isRunning() || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last measured memory usage for the process group (bytes)
|
||||
*/
|
||||
public getLastMemoryUsage(): number {
|
||||
return this.lastMemoryUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last measured CPU usage for the process group (sum of group, percent)
|
||||
*/
|
||||
public getLastCpuUsage(): number {
|
||||
return this.lastCpuUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for logging messages with the instance name.
|
||||
*/
|
||||
@@ -417,4 +573,17 @@ export class ProcessMonitor extends EventEmitter {
|
||||
const prefix = this.config.name ? `[${this.config.name}] ` : '';
|
||||
console.log(prefix + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate approximate memory size in bytes for a log entry.
|
||||
* Keeps CPU low by avoiding JSON.stringify on the full array.
|
||||
*/
|
||||
private estimateLogSize(log: IProcessLog): number {
|
||||
const messageBytes = Buffer.byteLength(log.message || '', 'utf8');
|
||||
const typeBytes = Buffer.byteLength(log.type || '', 'utf8');
|
||||
const runIdBytes = Buffer.byteLength((log as any).runId || '', 'utf8');
|
||||
// Rough overhead for object structure, keys, timestamp/seq values
|
||||
const overhead = 64;
|
||||
return messageBytes + typeBytes + runIdBytes + overhead;
|
||||
}
|
||||
}
|
||||
|
@@ -23,6 +23,33 @@ export class ProcessWrapper extends EventEmitter {
|
||||
private runId: string = '';
|
||||
private stdoutRemainder: string = '';
|
||||
private stderrRemainder: string = '';
|
||||
// Store event handlers for cleanup
|
||||
private exitHandler?: (code: number | null, signal: string | null) => void;
|
||||
private errorHandler?: (error: Error) => void;
|
||||
private stdoutDataHandler?: (data: Buffer) => void;
|
||||
private stdoutEndHandler?: () => void;
|
||||
private stderrDataHandler?: (data: Buffer) => void;
|
||||
private stderrEndHandler?: () => void;
|
||||
|
||||
// Helper: send a signal to the process and all its children (best-effort)
|
||||
private async killProcessTree(signal: NodeJS.Signals): Promise<void> {
|
||||
if (!this.process || !this.process.pid) return;
|
||||
const rootPid = this.process.pid;
|
||||
await new Promise<void>((resolve) => {
|
||||
plugins.psTree(rootPid, (err: any, children: ReadonlyArray<{ PID: string }>) => {
|
||||
const pids: number[] = [rootPid, ...children.map((c) => Number(c.PID)).filter((n) => Number.isFinite(n))];
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
// Always signal individual PIDs to avoid accidentally targeting unrelated groups
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// ignore ESRCH/EPERM
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: IProcessWrapperOptions) {
|
||||
super();
|
||||
@@ -64,7 +91,7 @@ export class ProcessWrapper extends EventEmitter {
|
||||
this.startTime = new Date();
|
||||
|
||||
// Handle process exit
|
||||
this.process.on('exit', (code, signal) => {
|
||||
this.exitHandler = (code, signal) => {
|
||||
const exitMessage = `Process exited with code ${code}, signal ${signal}`;
|
||||
this.logger.info(exitMessage);
|
||||
this.addSystemLog(exitMessage);
|
||||
@@ -73,11 +100,15 @@ export class ProcessWrapper extends EventEmitter {
|
||||
this.stdoutRemainder = '';
|
||||
this.stderrRemainder = '';
|
||||
|
||||
// Mark process reference as gone so isRunning() reflects reality
|
||||
this.process = null;
|
||||
|
||||
this.emit('exit', code, signal);
|
||||
});
|
||||
};
|
||||
this.process.on('exit', this.exitHandler);
|
||||
|
||||
// Handle errors
|
||||
this.process.on('error', (error) => {
|
||||
this.errorHandler = (error) => {
|
||||
const processError = new ProcessError(
|
||||
error.message,
|
||||
'ERR_PROCESS_EXECUTION',
|
||||
@@ -86,13 +117,24 @@ export class ProcessWrapper extends EventEmitter {
|
||||
this.logger.error(processError);
|
||||
this.addSystemLog(`Process error: ${processError.toString()}`);
|
||||
this.emit('error', processError);
|
||||
});
|
||||
};
|
||||
this.process.on('error', this.errorHandler);
|
||||
|
||||
// Capture stdout
|
||||
if (this.process.stdout) {
|
||||
console.error(`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`);
|
||||
this.process.stdout.on('data', (data) => {
|
||||
console.error(`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data.toString().substring(0, 100)}`);
|
||||
if (process.env.TSPM_DEBUG) {
|
||||
console.error(
|
||||
`[ProcessWrapper] Setting up stdout listener for process ${this.process.pid}`,
|
||||
);
|
||||
}
|
||||
this.stdoutDataHandler = (data) => {
|
||||
if (process.env.TSPM_DEBUG) {
|
||||
console.error(
|
||||
`[ProcessWrapper] Received stdout data from PID ${this.process?.pid}: ${data
|
||||
.toString()
|
||||
.substring(0, 100)}`,
|
||||
);
|
||||
}
|
||||
// Add data to remainder buffer and split by newlines
|
||||
const text = this.stdoutRemainder + data.toString();
|
||||
const lines = text.split('\n');
|
||||
@@ -102,27 +144,31 @@ export class ProcessWrapper extends EventEmitter {
|
||||
|
||||
// Process complete lines
|
||||
for (const line of lines) {
|
||||
console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
|
||||
if (process.env.TSPM_DEBUG) {
|
||||
console.error(`[ProcessWrapper] Processing stdout line: ${line}`);
|
||||
}
|
||||
this.logger.debug(`Captured stdout: ${line}`);
|
||||
this.addLog('stdout', line);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.process.stdout.on('data', this.stdoutDataHandler);
|
||||
|
||||
// Flush remainder on stream end
|
||||
this.process.stdout.on('end', () => {
|
||||
this.stdoutEndHandler = () => {
|
||||
if (this.stdoutRemainder) {
|
||||
this.logger.debug(`Flushing stdout remainder: ${this.stdoutRemainder}`);
|
||||
this.addLog('stdout', this.stdoutRemainder);
|
||||
this.stdoutRemainder = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
this.process.stdout.on('end', this.stdoutEndHandler);
|
||||
} else {
|
||||
this.logger.warn('Process stdout is null');
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
if (this.process.stderr) {
|
||||
this.process.stderr.on('data', (data) => {
|
||||
this.stderrDataHandler = (data) => {
|
||||
// Add data to remainder buffer and split by newlines
|
||||
const text = this.stderrRemainder + data.toString();
|
||||
const lines = text.split('\n');
|
||||
@@ -134,15 +180,17 @@ export class ProcessWrapper extends EventEmitter {
|
||||
for (const line of lines) {
|
||||
this.addLog('stderr', line);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.process.stderr.on('data', this.stderrDataHandler);
|
||||
|
||||
// Flush remainder on stream end
|
||||
this.process.stderr.on('end', () => {
|
||||
this.stderrEndHandler = () => {
|
||||
if (this.stderrRemainder) {
|
||||
this.addLog('stderr', this.stderrRemainder);
|
||||
this.stderrRemainder = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
this.process.stderr.on('end', this.stderrEndHandler);
|
||||
}
|
||||
|
||||
this.addSystemLog(`Process started with PID ${this.process.pid}`);
|
||||
@@ -165,46 +213,105 @@ export class ProcessWrapper extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up event listeners from process and streams
|
||||
*/
|
||||
private cleanupListeners(): void {
|
||||
if (this.process) {
|
||||
if (this.exitHandler) {
|
||||
this.process.removeListener('exit', this.exitHandler);
|
||||
}
|
||||
if (this.errorHandler) {
|
||||
this.process.removeListener('error', this.errorHandler);
|
||||
}
|
||||
|
||||
if (this.process.stdout) {
|
||||
if (this.stdoutDataHandler) {
|
||||
this.process.stdout.removeListener('data', this.stdoutDataHandler);
|
||||
}
|
||||
if (this.stdoutEndHandler) {
|
||||
this.process.stdout.removeListener('end', this.stdoutEndHandler);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.process.stderr) {
|
||||
if (this.stderrDataHandler) {
|
||||
this.process.stderr.removeListener('data', this.stderrDataHandler);
|
||||
}
|
||||
if (this.stderrEndHandler) {
|
||||
this.process.stderr.removeListener('end', this.stderrEndHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear references
|
||||
this.exitHandler = undefined;
|
||||
this.errorHandler = undefined;
|
||||
this.stdoutDataHandler = undefined;
|
||||
this.stdoutEndHandler = undefined;
|
||||
this.stderrDataHandler = undefined;
|
||||
this.stderrEndHandler = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the wrapped process
|
||||
*/
|
||||
public stop(): void {
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.process) {
|
||||
this.logger.debug('Stop called but no process is running');
|
||||
this.addSystemLog('No process running');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up event listeners before stopping
|
||||
this.cleanupListeners();
|
||||
|
||||
this.logger.info('Stopping process...');
|
||||
this.addSystemLog('Stopping process...');
|
||||
|
||||
// First try SIGTERM for graceful shutdown
|
||||
if (this.process.pid) {
|
||||
try {
|
||||
this.logger.debug(`Sending SIGTERM to process ${this.process.pid}`);
|
||||
process.kill(this.process.pid, 'SIGTERM');
|
||||
this.logger.debug(`Sending SIGTERM to process tree rooted at ${this.process.pid}`);
|
||||
await this.killProcessTree('SIGTERM');
|
||||
|
||||
// Give it 5 seconds to shut down gracefully
|
||||
setTimeout((): void => {
|
||||
if (this.process && this.process.pid) {
|
||||
// If the process already exited, return immediately
|
||||
if (typeof this.process.exitCode === 'number') {
|
||||
this.logger.debug('Process already exited, no need to wait');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for exit or escalate
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onExit = () => cleanup();
|
||||
this.process!.once('exit', onExit);
|
||||
|
||||
const killTimer = setTimeout(async () => {
|
||||
if (!this.process || !this.process.pid) return cleanup();
|
||||
this.logger.warn(
|
||||
`Process ${this.process.pid} did not exit gracefully, force killing...`,
|
||||
);
|
||||
this.addSystemLog(
|
||||
'Process did not exit gracefully, force killing...',
|
||||
`Process ${this.process.pid} did not exit gracefully, force killing tree...`,
|
||||
);
|
||||
this.addSystemLog('Process did not exit gracefully, force killing...');
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGKILL');
|
||||
} catch (error: Error | unknown) {
|
||||
// Process might have exited between checks
|
||||
this.logger.debug(
|
||||
`Failed to send SIGKILL, process probably already exited: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
await this.killProcessTree('SIGKILL');
|
||||
} catch {}
|
||||
// Give a short grace period after SIGKILL
|
||||
setTimeout(() => cleanup(), 500);
|
||||
}, 5000);
|
||||
|
||||
// Safety cap in case neither exit nor timer fires (shouldn't happen)
|
||||
setTimeout(() => {
|
||||
clearTimeout(killTimer);
|
||||
cleanup();
|
||||
}, 10000);
|
||||
});
|
||||
} catch (error: Error | unknown) {
|
||||
const processError = new ProcessError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
@@ -221,6 +328,7 @@ export class ProcessWrapper extends EventEmitter {
|
||||
* Get the process ID if running
|
||||
*/
|
||||
public getPid(): number | null {
|
||||
if (!this.isRunning()) return null;
|
||||
return this.process?.pid || null;
|
||||
}
|
||||
|
||||
@@ -244,7 +352,13 @@ export class ProcessWrapper extends EventEmitter {
|
||||
* Check if the process is currently running
|
||||
*/
|
||||
public isRunning(): boolean {
|
||||
return this.process !== null && typeof this.process.exitCode !== 'number';
|
||||
if (!this.process) return false;
|
||||
// In Node, while the child is running: exitCode === null and signalCode === null/undefined
|
||||
// After it exits: exitCode is a number OR signalCode is a string
|
||||
const anyProc: any = this.process as any;
|
||||
const exitCode = anyProc.exitCode;
|
||||
const signalCode = anyProc.signalCode;
|
||||
return exitCode === null && (signalCode === null || typeof signalCode === 'undefined');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -10,6 +10,7 @@ import type {
|
||||
DaemonStatusResponse,
|
||||
HeartbeatResponse,
|
||||
} from '../shared/protocol/ipc.types.js';
|
||||
import { LogPersistence } from './logpersistence.js';
|
||||
|
||||
/**
|
||||
* Central daemon server that manages all TSPM processes
|
||||
@@ -97,9 +98,25 @@ export class TspmDaemon {
|
||||
this.tspmInstance.on('process:log', ({ processId, log }) => {
|
||||
// Publish to topic for this process
|
||||
const topic = `logs.${processId}`;
|
||||
// Broadcast to all connected clients subscribed to this topic
|
||||
// Deliver only to subscribed clients
|
||||
if (this.ipcServer) {
|
||||
this.ipcServer.broadcast(`topic:${topic}`, log);
|
||||
try {
|
||||
const topicIndex = (this.ipcServer as any).topicIndex as Map<string, Set<string>> | undefined;
|
||||
const subscribers = topicIndex?.get(topic);
|
||||
if (subscribers && subscribers.size > 0) {
|
||||
// Send directly to subscribers for this topic
|
||||
for (const clientId of subscribers) {
|
||||
this.ipcServer
|
||||
.sendToClient(clientId, `topic:${topic}`, log)
|
||||
.catch((err: any) => {
|
||||
// Surface but don't fail the loop
|
||||
console.error('[IPC] sendToClient error:', err?.message || err);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[IPC] Topic delivery error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -154,7 +171,22 @@ export class TspmDaemon {
|
||||
throw new Error(`Process ${id} not found`);
|
||||
}
|
||||
await this.tspmInstance.setDesiredState(id, 'online');
|
||||
await this.tspmInstance.start(config);
|
||||
const existing = this.tspmInstance.processes.get(id);
|
||||
if (existing) {
|
||||
if (existing.isRunning()) {
|
||||
// Already running; return current status/pid
|
||||
const runningInfo = this.tspmInstance.processInfo.get(id);
|
||||
return {
|
||||
processId: id,
|
||||
pid: runningInfo?.pid,
|
||||
status: runningInfo?.status || 'online',
|
||||
};
|
||||
} else {
|
||||
await this.tspmInstance.restart(id);
|
||||
}
|
||||
} else {
|
||||
await this.tspmInstance.start(config);
|
||||
}
|
||||
const processInfo = this.tspmInstance.processInfo.get(id);
|
||||
return {
|
||||
processId: id,
|
||||
@@ -277,11 +309,80 @@ export class TspmDaemon {
|
||||
this.ipcServer.onMessage(
|
||||
'getLogs',
|
||||
async (request: RequestForMethod<'getLogs'>) => {
|
||||
const logs = await this.tspmInstance.getLogs(toProcessId(request.id));
|
||||
const id = toProcessId(request.id);
|
||||
const logs = await this.tspmInstance.getLogs(id, request.lines);
|
||||
return { logs };
|
||||
},
|
||||
);
|
||||
|
||||
// Stream backlog logs and let client subscribe to live topic separately
|
||||
this.ipcServer.onMessage(
|
||||
'logs:subscribe',
|
||||
async (
|
||||
request: RequestForMethod<'logs:subscribe'>,
|
||||
clientId: string,
|
||||
) => {
|
||||
const id = toProcessId(request.id);
|
||||
// Determine backlog set
|
||||
const allLogs = await this.tspmInstance.getLogs(id);
|
||||
let filtered = allLogs;
|
||||
if (request.types && request.types.length) {
|
||||
filtered = filtered.filter((l) => request.types!.includes(l.type));
|
||||
}
|
||||
if (request.sinceTime && request.sinceTime > 0) {
|
||||
filtered = filtered.filter(
|
||||
(l) => new Date(l.timestamp).getTime() >= request.sinceTime!,
|
||||
);
|
||||
}
|
||||
const lines = request.lines && request.lines > 0 ? request.lines : 0;
|
||||
if (lines > 0 && filtered.length > lines) {
|
||||
filtered = filtered.slice(-lines);
|
||||
}
|
||||
|
||||
// Send backlog entries directly to the requesting client as topic messages
|
||||
// in small batches to avoid overwhelming the transport or client.
|
||||
const chunkSize = 200;
|
||||
for (let i = 0; i < filtered.length; i += chunkSize) {
|
||||
const chunk = filtered.slice(i, i + chunkSize);
|
||||
await Promise.allSettled(
|
||||
chunk.map((entry) =>
|
||||
this.ipcServer.sendToClient(
|
||||
clientId,
|
||||
`topic:logs.backlog.${id}`,
|
||||
{
|
||||
...entry,
|
||||
timestamp: new Date(entry.timestamp).getTime(),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
// Yield a bit between chunks
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
return { ok: true } as any;
|
||||
},
|
||||
);
|
||||
|
||||
// Inspect subscribers for a process log topic
|
||||
this.ipcServer.onMessage(
|
||||
'logs:subscribers',
|
||||
async (
|
||||
request: RequestForMethod<'logs:subscribers'>,
|
||||
clientId: string,
|
||||
) => {
|
||||
const id = toProcessId(request.id);
|
||||
const topic = `logs.${id}`;
|
||||
try {
|
||||
const topicIndex = (this.ipcServer as any).topicIndex as Map<string, Set<string>> | undefined;
|
||||
const subs = Array.from(topicIndex?.get(topic) || []);
|
||||
// Also include the requesting clientId if it has a local handler without subscription
|
||||
return { topic, subscribers: subs, count: subs.length } as any;
|
||||
} catch (err: any) {
|
||||
return { topic, subscribers: [], count: 0 } as any;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve target (id:n | name:foo | numeric string) to ProcessId
|
||||
this.ipcServer.onMessage(
|
||||
'resolveTarget',
|
||||
@@ -365,10 +466,12 @@ export class TspmDaemon {
|
||||
|
||||
await this.tspmInstance.setDesiredStateForAll('stopped');
|
||||
await this.tspmInstance.stopAll();
|
||||
// Yield briefly to allow any pending exit events to settle
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Get status of all processes
|
||||
for (const [id, processInfo] of this.tspmInstance.processInfo) {
|
||||
if (processInfo.status === 'stopped') {
|
||||
// Determine which monitors are no longer running
|
||||
for (const [id, monitor] of this.tspmInstance.processes) {
|
||||
if (!monitor.isRunning()) {
|
||||
stopped.push(id);
|
||||
} else {
|
||||
failed.push({ id, error: 'Failed to stop' });
|
||||
@@ -414,6 +517,28 @@ export class TspmDaemon {
|
||||
'daemon:status',
|
||||
async (request: RequestForMethod<'daemon:status'>) => {
|
||||
const memUsage = process.memoryUsage();
|
||||
// Aggregate log stats from monitors
|
||||
let totalLogCount = 0;
|
||||
let totalLogBytes = 0;
|
||||
const perProcess: Array<{ id: ProcessId; count: number; bytes: number }> = [];
|
||||
for (const [id, monitor] of this.tspmInstance.processes.entries()) {
|
||||
try {
|
||||
const logs = monitor.getLogs();
|
||||
const count = logs.length;
|
||||
const bytes = LogPersistence.calculateLogMemorySize(logs);
|
||||
totalLogCount += count;
|
||||
totalLogBytes += bytes;
|
||||
perProcess.push({ id, count, bytes });
|
||||
} catch {}
|
||||
}
|
||||
const pathsInfo = {
|
||||
tspmDir: paths.tspmDir,
|
||||
socketPath: this.socketPath,
|
||||
pidFile: this.daemonPidFile,
|
||||
};
|
||||
const configsInfo = {
|
||||
processConfigs: this.tspmInstance.processConfigs.size,
|
||||
};
|
||||
return {
|
||||
status: 'running',
|
||||
pid: process.pid,
|
||||
@@ -422,6 +547,13 @@ export class TspmDaemon {
|
||||
memoryUsage: memUsage.heapUsed,
|
||||
cpuUsage: process.cpuUsage().user / 1000000, // Convert to seconds
|
||||
version: this.version,
|
||||
logsInMemory: {
|
||||
totalCount: totalLogCount,
|
||||
totalBytes: totalLogBytes,
|
||||
perProcess,
|
||||
},
|
||||
paths: pathsInfo,
|
||||
configs: configsInfo,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
@@ -139,6 +139,29 @@ export interface GetLogsResponse {
|
||||
logs: IProcessLog[];
|
||||
}
|
||||
|
||||
// Subscribe and stream backlog logs
|
||||
export interface LogsSubscribeRequest {
|
||||
id: ProcessId;
|
||||
lines?: number; // number of backlog lines
|
||||
sinceTime?: number; // ms epoch
|
||||
types?: Array<IProcessLog['type']>;
|
||||
}
|
||||
|
||||
export interface LogsSubscribeResponse {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
// Inspect current subscribers for a process log topic
|
||||
export interface LogsSubscribersRequest {
|
||||
id: ProcessId;
|
||||
}
|
||||
|
||||
export interface LogsSubscribersResponse {
|
||||
topic: string;
|
||||
subscribers: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Start all command
|
||||
export interface StartAllRequest {
|
||||
// No parameters needed
|
||||
@@ -205,6 +228,20 @@ export interface DaemonStatusResponse {
|
||||
memoryUsage?: number;
|
||||
cpuUsage?: number;
|
||||
version?: string;
|
||||
// Additional metadata (optional)
|
||||
paths?: {
|
||||
tspmDir?: string;
|
||||
socketPath?: string;
|
||||
pidFile?: string;
|
||||
};
|
||||
configs?: {
|
||||
processConfigs?: number;
|
||||
};
|
||||
logsInMemory?: {
|
||||
totalCount: number;
|
||||
totalBytes: number;
|
||||
perProcess: Array<{ id: ProcessId; count: number; bytes: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
// Daemon shutdown command
|
||||
@@ -274,6 +311,8 @@ export type IpcMethodMap = {
|
||||
list: { request: ListRequest; response: ListResponse };
|
||||
describe: { request: DescribeRequest; response: DescribeResponse };
|
||||
getLogs: { request: GetLogsRequest; response: GetLogsResponse };
|
||||
'logs:subscribe': { request: LogsSubscribeRequest; response: LogsSubscribeResponse };
|
||||
'logs:subscribers': { request: LogsSubscribersRequest; response: LogsSubscribersResponse };
|
||||
startAll: { request: StartAllRequest; response: StartAllResponse };
|
||||
stopAll: { request: StopAllRequest; response: StopAllResponse };
|
||||
restartAll: { request: RestartAllRequest; response: RestartAllResponse };
|
||||
|
Reference in New Issue
Block a user