@grinev/opencode-telegram-bot 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/README.md +72 -42
- package/dist/app/start-bot-app.js +76 -8
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/opencode-start.js +27 -24
- package/dist/bot/commands/opencode-stop.js +51 -23
- package/dist/bot/commands/projects.js +30 -6
- package/dist/bot/commands/skills.js +376 -0
- package/dist/bot/commands/status.js +21 -14
- package/dist/bot/commands/worktree.js +196 -0
- package/dist/bot/handlers/inline-menu.js +1 -0
- package/dist/bot/handlers/voice.js +8 -1
- package/dist/bot/index.js +39 -4
- package/dist/cli/args.js +36 -7
- package/dist/cli.js +131 -17
- package/dist/config.js +3 -0
- package/dist/git/worktree.js +158 -0
- package/dist/i18n/de.js +39 -11
- package/dist/i18n/en.js +39 -11
- package/dist/i18n/es.js +39 -11
- package/dist/i18n/fr.js +39 -11
- package/dist/i18n/ru.js +39 -11
- package/dist/i18n/zh.js +39 -11
- package/dist/opencode/process.js +182 -0
- package/dist/pinned/manager.js +28 -61
- package/dist/project/manager.js +47 -32
- package/dist/runtime/bootstrap.js +119 -7
- package/dist/scheduled-task/executor.js +137 -7
- package/dist/scheduled-task/runtime.js +8 -0
- package/dist/service/manager.js +244 -0
- package/dist/service/runtime.js +19 -0
- package/dist/settings/manager.js +9 -12
- package/package.json +1 -1
- package/dist/process/manager.js +0 -273
- /package/dist/{process → service}/types.js +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { exec, spawn } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { getRuntimePaths } from "../runtime/paths.js";
|
|
7
|
+
import { buildServiceChildEnv } from "./runtime.js";
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const SERVICE_STATE_FILE_NAME = "bot-service.json";
|
|
10
|
+
const PROCESS_EXIT_POLL_MS = 100;
|
|
11
|
+
const DEFAULT_STOP_TIMEOUT_MS = 5000;
|
|
12
|
+
function sanitizeTimestampForFile(timestamp) {
|
|
13
|
+
return timestamp.replace(/:/g, "-").replace("T", "_");
|
|
14
|
+
}
|
|
15
|
+
function createServiceLogFilePath(logsDirPath) {
|
|
16
|
+
const timestamp = sanitizeTimestampForFile(new Date().toISOString().slice(0, 19));
|
|
17
|
+
return path.join(logsDirPath, `bot-service-${timestamp}.log`);
|
|
18
|
+
}
|
|
19
|
+
function isValidServiceState(value) {
|
|
20
|
+
if (!value || typeof value !== "object") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const candidate = value;
|
|
24
|
+
return (typeof candidate.pid === "number" &&
|
|
25
|
+
Number.isInteger(candidate.pid) &&
|
|
26
|
+
candidate.pid > 0 &&
|
|
27
|
+
typeof candidate.startedAt === "string" &&
|
|
28
|
+
candidate.startedAt.length > 0 &&
|
|
29
|
+
typeof candidate.logFilePath === "string" &&
|
|
30
|
+
candidate.logFilePath.length > 0 &&
|
|
31
|
+
candidate.mode === "daemon");
|
|
32
|
+
}
|
|
33
|
+
async function writeFileAtomically(filePath, content) {
|
|
34
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
35
|
+
const tempFilePath = `${filePath}.${process.pid}.tmp`;
|
|
36
|
+
await fsPromises.writeFile(tempFilePath, content, "utf-8");
|
|
37
|
+
await fsPromises.rename(tempFilePath, filePath);
|
|
38
|
+
}
|
|
39
|
+
async function readServiceStateFile(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
const content = await fsPromises.readFile(filePath, "utf-8");
|
|
42
|
+
const parsed = JSON.parse(content);
|
|
43
|
+
if (!isValidServiceState(parsed)) {
|
|
44
|
+
await clearServiceStateFile(filePath);
|
|
45
|
+
return { service: null, cleanupReason: "invalid" };
|
|
46
|
+
}
|
|
47
|
+
return { service: parsed, cleanupReason: null };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === "ENOENT") {
|
|
51
|
+
return { service: null, cleanupReason: null };
|
|
52
|
+
}
|
|
53
|
+
if (error instanceof SyntaxError) {
|
|
54
|
+
await clearServiceStateFile(filePath);
|
|
55
|
+
return { service: null, cleanupReason: "invalid" };
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isProcessAlive(pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
72
|
+
if (!isProcessAlive(pid)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, PROCESS_EXIT_POLL_MS));
|
|
76
|
+
}
|
|
77
|
+
return !isProcessAlive(pid);
|
|
78
|
+
}
|
|
79
|
+
function getServiceEntryScriptPath() {
|
|
80
|
+
const scriptPath = process.argv[1];
|
|
81
|
+
if (!scriptPath || scriptPath.trim().length === 0) {
|
|
82
|
+
throw new Error("Failed to resolve CLI entry script path.");
|
|
83
|
+
}
|
|
84
|
+
return path.resolve(scriptPath);
|
|
85
|
+
}
|
|
86
|
+
async function stopWindowsProcess(pid, timeoutMs) {
|
|
87
|
+
try {
|
|
88
|
+
await execAsync(`taskkill /PID ${pid} /T`);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Continue with forced stop if the process is still alive.
|
|
92
|
+
}
|
|
93
|
+
if (await waitForProcessExit(pid, timeoutMs)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await execAsync(`taskkill /F /PID ${pid} /T`);
|
|
97
|
+
await waitForProcessExit(pid, timeoutMs);
|
|
98
|
+
}
|
|
99
|
+
async function stopUnixProcess(pid, timeoutMs) {
|
|
100
|
+
process.kill(pid, "SIGTERM");
|
|
101
|
+
if (await waitForProcessExit(pid, timeoutMs)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
process.kill(pid, "SIGKILL");
|
|
105
|
+
await waitForProcessExit(pid, timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
export function getServiceStateFilePath() {
|
|
108
|
+
return path.join(getRuntimePaths().runDirPath, SERVICE_STATE_FILE_NAME);
|
|
109
|
+
}
|
|
110
|
+
export async function clearServiceStateFile(filePath = getServiceStateFilePath()) {
|
|
111
|
+
try {
|
|
112
|
+
await fsPromises.unlink(filePath);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== "ENOENT") {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function getBotServiceStatus() {
|
|
121
|
+
const stateFilePath = getServiceStateFilePath();
|
|
122
|
+
const { service, cleanupReason } = await readServiceStateFile(stateFilePath);
|
|
123
|
+
if (!service) {
|
|
124
|
+
return {
|
|
125
|
+
status: "stopped",
|
|
126
|
+
service: null,
|
|
127
|
+
cleanupReason,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (!isProcessAlive(service.pid)) {
|
|
131
|
+
await clearServiceStateFile(stateFilePath);
|
|
132
|
+
return {
|
|
133
|
+
status: "stopped",
|
|
134
|
+
service: null,
|
|
135
|
+
cleanupReason: "stale",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: "running",
|
|
140
|
+
service,
|
|
141
|
+
cleanupReason,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export async function startBotDaemon(mode) {
|
|
145
|
+
const currentStatus = await getBotServiceStatus();
|
|
146
|
+
if (currentStatus.status === "running" && currentStatus.service) {
|
|
147
|
+
return {
|
|
148
|
+
success: false,
|
|
149
|
+
service: currentStatus.service,
|
|
150
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
151
|
+
alreadyRunning: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const runtimePaths = getRuntimePaths();
|
|
155
|
+
await Promise.all([
|
|
156
|
+
fsPromises.mkdir(runtimePaths.runDirPath, { recursive: true }),
|
|
157
|
+
fsPromises.mkdir(runtimePaths.logsDirPath, { recursive: true }),
|
|
158
|
+
]);
|
|
159
|
+
const stateFilePath = getServiceStateFilePath();
|
|
160
|
+
const logFilePath = createServiceLogFilePath(runtimePaths.logsDirPath);
|
|
161
|
+
const logFileDescriptor = fs.openSync(logFilePath, "a");
|
|
162
|
+
try {
|
|
163
|
+
const childArgs = [getServiceEntryScriptPath(), "start"];
|
|
164
|
+
if (mode) {
|
|
165
|
+
childArgs.push("--mode", mode);
|
|
166
|
+
}
|
|
167
|
+
const childProcess = spawn(process.execPath, childArgs, {
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio: ["ignore", logFileDescriptor, logFileDescriptor],
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
env: buildServiceChildEnv(process.env, stateFilePath),
|
|
172
|
+
});
|
|
173
|
+
if (!childProcess.pid) {
|
|
174
|
+
throw new Error("Failed to start background bot process.");
|
|
175
|
+
}
|
|
176
|
+
childProcess.unref();
|
|
177
|
+
const serviceState = {
|
|
178
|
+
pid: childProcess.pid,
|
|
179
|
+
startedAt: new Date().toISOString(),
|
|
180
|
+
logFilePath,
|
|
181
|
+
mode: "daemon",
|
|
182
|
+
};
|
|
183
|
+
await writeFileAtomically(stateFilePath, `${JSON.stringify(serviceState, null, 2)}\n`);
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
service: serviceState,
|
|
187
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
await clearServiceStateFile(stateFilePath);
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
service: null,
|
|
195
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
fs.closeSync(logFileDescriptor);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
|
|
204
|
+
const currentStatus = await getBotServiceStatus();
|
|
205
|
+
if (currentStatus.status !== "running" || !currentStatus.service) {
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
service: null,
|
|
209
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
210
|
+
alreadyStopped: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const { pid } = currentStatus.service;
|
|
214
|
+
try {
|
|
215
|
+
if (process.platform === "win32") {
|
|
216
|
+
await stopWindowsProcess(pid, timeoutMs);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
await stopUnixProcess(pid, timeoutMs);
|
|
220
|
+
}
|
|
221
|
+
if (isProcessAlive(pid)) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
service: currentStatus.service,
|
|
225
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
226
|
+
error: `Failed to stop background bot process PID=${pid}.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
await clearServiceStateFile();
|
|
230
|
+
return {
|
|
231
|
+
success: true,
|
|
232
|
+
service: currentStatus.service,
|
|
233
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
service: currentStatus.service,
|
|
240
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
241
|
+
error: error instanceof Error ? error.message : String(error),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SERVICE_CHILD_ENV_KEY = "OPENCODE_TELEGRAM_SERVICE_CHILD";
|
|
2
|
+
const SERVICE_STATE_PATH_ENV_KEY = "OPENCODE_TELEGRAM_SERVICE_STATE_PATH";
|
|
3
|
+
export function buildServiceChildEnv(baseEnv, stateFilePath) {
|
|
4
|
+
return {
|
|
5
|
+
...baseEnv,
|
|
6
|
+
[SERVICE_CHILD_ENV_KEY]: "1",
|
|
7
|
+
[SERVICE_STATE_PATH_ENV_KEY]: stateFilePath,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function isServiceChildProcess() {
|
|
11
|
+
return process.env[SERVICE_CHILD_ENV_KEY] === "1";
|
|
12
|
+
}
|
|
13
|
+
export function getServiceStateFilePathFromEnv() {
|
|
14
|
+
const value = process.env[SERVICE_STATE_PATH_ENV_KEY];
|
|
15
|
+
if (!value || value.trim().length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
package/dist/settings/manager.js
CHANGED
|
@@ -103,17 +103,6 @@ export function clearPinnedMessageId() {
|
|
|
103
103
|
currentSettings.pinnedMessageId = undefined;
|
|
104
104
|
void writeSettingsFile(currentSettings);
|
|
105
105
|
}
|
|
106
|
-
export function getServerProcess() {
|
|
107
|
-
return currentSettings.serverProcess;
|
|
108
|
-
}
|
|
109
|
-
export function setServerProcess(processInfo) {
|
|
110
|
-
currentSettings.serverProcess = processInfo;
|
|
111
|
-
void writeSettingsFile(currentSettings);
|
|
112
|
-
}
|
|
113
|
-
export function clearServerProcess() {
|
|
114
|
-
currentSettings.serverProcess = undefined;
|
|
115
|
-
void writeSettingsFile(currentSettings);
|
|
116
|
-
}
|
|
117
106
|
export function getSessionDirectoryCache() {
|
|
118
107
|
return currentSettings.sessionDirectoryCache;
|
|
119
108
|
}
|
|
@@ -138,10 +127,18 @@ export function __resetSettingsForTests() {
|
|
|
138
127
|
}
|
|
139
128
|
export async function loadSettings() {
|
|
140
129
|
const loadedSettings = (await readSettingsFile());
|
|
130
|
+
let requiresRewrite = false;
|
|
141
131
|
if ("toolMessagesIntervalSec" in loadedSettings) {
|
|
142
132
|
delete loadedSettings.toolMessagesIntervalSec;
|
|
143
|
-
|
|
133
|
+
requiresRewrite = true;
|
|
134
|
+
}
|
|
135
|
+
if ("serverProcess" in loadedSettings) {
|
|
136
|
+
delete loadedSettings.serverProcess;
|
|
137
|
+
requiresRewrite = true;
|
|
144
138
|
}
|
|
145
139
|
currentSettings = loadedSettings;
|
|
146
140
|
currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
|
|
141
|
+
if (requiresRewrite) {
|
|
142
|
+
void writeSettingsFile(currentSettings);
|
|
143
|
+
}
|
|
147
144
|
}
|
package/package.json
CHANGED
package/dist/process/manager.js
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { spawn, exec } from "child_process";
|
|
2
|
-
import { promisify } from "util";
|
|
3
|
-
import { getServerProcess, setServerProcess, clearServerProcess } from "../settings/manager.js";
|
|
4
|
-
import { logger } from "../utils/logger.js";
|
|
5
|
-
const execAsync = promisify(exec);
|
|
6
|
-
/**
|
|
7
|
-
* Singleton manager for OpenCode server process
|
|
8
|
-
* Handles starting, stopping, and monitoring the server process
|
|
9
|
-
* Persists PID to settings.json for recovery after bot restart
|
|
10
|
-
*/
|
|
11
|
-
class ProcessManager {
|
|
12
|
-
state = {
|
|
13
|
-
process: null,
|
|
14
|
-
pid: null,
|
|
15
|
-
startTime: null,
|
|
16
|
-
isRunning: false,
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* Initialize the manager by restoring state from settings
|
|
20
|
-
* Checks if the stored process is still alive
|
|
21
|
-
*/
|
|
22
|
-
async initialize() {
|
|
23
|
-
const savedProcess = getServerProcess();
|
|
24
|
-
if (!savedProcess) {
|
|
25
|
-
logger.debug("[ProcessManager] No saved process found in settings");
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
logger.info(`[ProcessManager] Found saved process: PID=${savedProcess.pid}`);
|
|
29
|
-
// Check if the process is still alive
|
|
30
|
-
if (this.isProcessAlive(savedProcess.pid)) {
|
|
31
|
-
logger.info(`[ProcessManager] Process PID=${savedProcess.pid} is still alive, restoring state`);
|
|
32
|
-
this.state = {
|
|
33
|
-
process: null, // Cannot recover ChildProcess reference
|
|
34
|
-
pid: savedProcess.pid,
|
|
35
|
-
startTime: new Date(savedProcess.startTime),
|
|
36
|
-
isRunning: true,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
logger.warn(`[ProcessManager] Process PID=${savedProcess.pid} is dead, cleaning up`);
|
|
41
|
-
clearServerProcess();
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Start the OpenCode server process
|
|
46
|
-
*/
|
|
47
|
-
async start() {
|
|
48
|
-
if (this.state.isRunning) {
|
|
49
|
-
return {
|
|
50
|
-
success: false,
|
|
51
|
-
error: "Process already running",
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
logger.info("[ProcessManager] Starting OpenCode server process...");
|
|
56
|
-
const isWindows = process.platform === "win32";
|
|
57
|
-
const command = isWindows ? "cmd.exe" : "opencode";
|
|
58
|
-
const args = isWindows ? ["/c", "opencode", "serve"] : ["serve"];
|
|
59
|
-
// Spawn the process
|
|
60
|
-
// Windows: use cmd.exe to resolve npm-installed global commands
|
|
61
|
-
// Unix-like: run opencode directly
|
|
62
|
-
const childProcess = spawn(command, args, {
|
|
63
|
-
detached: false,
|
|
64
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
65
|
-
windowsHide: isWindows,
|
|
66
|
-
});
|
|
67
|
-
if (!childProcess.pid) {
|
|
68
|
-
throw new Error("Failed to start OpenCode server process. Ensure 'opencode' is installed and available in PATH.");
|
|
69
|
-
}
|
|
70
|
-
// Setup event handlers
|
|
71
|
-
childProcess.on("error", (err) => {
|
|
72
|
-
logger.error("[ProcessManager] Process error:", err);
|
|
73
|
-
this.cleanup();
|
|
74
|
-
});
|
|
75
|
-
childProcess.on("exit", (code, signal) => {
|
|
76
|
-
logger.info(`[ProcessManager] Process exited: code=${code}, signal=${signal}`);
|
|
77
|
-
this.cleanup();
|
|
78
|
-
});
|
|
79
|
-
// Log stdout/stderr
|
|
80
|
-
if (childProcess.stdout) {
|
|
81
|
-
childProcess.stdout.on("data", (data) => {
|
|
82
|
-
logger.debug(`[OpenCode Server] ${data.toString().trim()}`);
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
if (childProcess.stderr) {
|
|
86
|
-
childProcess.stderr.on("data", (data) => {
|
|
87
|
-
logger.warn(`[OpenCode Server Error] ${data.toString().trim()}`);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
// Save state in memory
|
|
91
|
-
const startTime = new Date();
|
|
92
|
-
this.state = {
|
|
93
|
-
process: childProcess,
|
|
94
|
-
pid: childProcess.pid,
|
|
95
|
-
startTime,
|
|
96
|
-
isRunning: true,
|
|
97
|
-
};
|
|
98
|
-
// Persist to settings.json
|
|
99
|
-
setServerProcess({
|
|
100
|
-
pid: childProcess.pid,
|
|
101
|
-
startTime: startTime.toISOString(),
|
|
102
|
-
});
|
|
103
|
-
logger.info(`[ProcessManager] OpenCode server started with PID=${childProcess.pid}`);
|
|
104
|
-
return { success: true };
|
|
105
|
-
}
|
|
106
|
-
catch (err) {
|
|
107
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
108
|
-
logger.error("[ProcessManager] Failed to start process:", err);
|
|
109
|
-
this.cleanup();
|
|
110
|
-
return { success: false, error: errorMessage };
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Stop the OpenCode server process
|
|
115
|
-
* Sends SIGINT (Ctrl+C) and waits for graceful shutdown
|
|
116
|
-
* Falls back to SIGKILL if timeout is exceeded
|
|
117
|
-
*/
|
|
118
|
-
async stop(timeoutMs = 5000) {
|
|
119
|
-
if (!this.state.isRunning || !this.state.pid) {
|
|
120
|
-
return {
|
|
121
|
-
success: false,
|
|
122
|
-
error: "Process not running",
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
const pid = this.state.pid;
|
|
127
|
-
logger.info(`[ProcessManager] Stopping process PID=${pid}...`);
|
|
128
|
-
// On Windows, use taskkill to kill the entire process tree
|
|
129
|
-
// This is necessary because cmd.exe spawns child processes
|
|
130
|
-
if (process.platform === "win32") {
|
|
131
|
-
try {
|
|
132
|
-
// /F = force terminate, /T = terminate tree, /PID = process id
|
|
133
|
-
logger.debug(`[ProcessManager] Using taskkill to terminate process tree for PID=${pid}`);
|
|
134
|
-
await execAsync(`taskkill /F /T /PID ${pid}`);
|
|
135
|
-
logger.info(`[ProcessManager] Process tree terminated successfully for PID=${pid}`);
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
// taskkill returns error if process not found, which is ok
|
|
139
|
-
const error = err;
|
|
140
|
-
if (error.message?.includes("not found")) {
|
|
141
|
-
logger.debug(`[ProcessManager] Process PID=${pid} already terminated`);
|
|
142
|
-
}
|
|
143
|
-
else {
|
|
144
|
-
logger.warn(`[ProcessManager] taskkill error for PID=${pid}:`, err);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
// Wait a bit for cleanup
|
|
148
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
// Unix-like systems: use SIGINT/SIGKILL
|
|
152
|
-
if (this.state.process) {
|
|
153
|
-
const childProcess = this.state.process;
|
|
154
|
-
// Send SIGINT (Ctrl+C)
|
|
155
|
-
logger.debug(`[ProcessManager] Sending SIGINT to PID=${pid}`);
|
|
156
|
-
childProcess.kill("SIGINT");
|
|
157
|
-
// Wait for graceful shutdown
|
|
158
|
-
const gracefulExit = await this.waitForProcessExit(childProcess, timeoutMs);
|
|
159
|
-
if (!gracefulExit && this.state.isRunning) {
|
|
160
|
-
logger.warn(`[ProcessManager] Graceful shutdown failed, sending SIGKILL to PID=${pid}`);
|
|
161
|
-
childProcess.kill("SIGKILL");
|
|
162
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
// No ChildProcess reference (recovered from settings)
|
|
167
|
-
logger.debug(`[ProcessManager] Sending SIGTERM to PID=${pid}`);
|
|
168
|
-
try {
|
|
169
|
-
process.kill(pid, "SIGTERM");
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
logger.debug(`[ProcessManager] Failed to send SIGTERM to PID=${pid}:`, err);
|
|
173
|
-
}
|
|
174
|
-
// Wait for process to die
|
|
175
|
-
await new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
176
|
-
// Check if still alive
|
|
177
|
-
if (this.isProcessAlive(pid)) {
|
|
178
|
-
logger.warn(`[ProcessManager] Graceful shutdown failed, sending SIGKILL to PID=${pid}`);
|
|
179
|
-
try {
|
|
180
|
-
process.kill(pid, "SIGKILL");
|
|
181
|
-
}
|
|
182
|
-
catch (err) {
|
|
183
|
-
logger.error(`[ProcessManager] Failed to send SIGKILL to PID=${pid}:`, err);
|
|
184
|
-
}
|
|
185
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
this.cleanup();
|
|
190
|
-
logger.info(`[ProcessManager] Process PID=${pid} stopped successfully`);
|
|
191
|
-
return { success: true };
|
|
192
|
-
}
|
|
193
|
-
catch (err) {
|
|
194
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
195
|
-
logger.error("[ProcessManager] Failed to stop process:", err);
|
|
196
|
-
return { success: false, error: errorMessage };
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Check if the process is running
|
|
201
|
-
* Validates that the process with stored PID is actually alive
|
|
202
|
-
*/
|
|
203
|
-
isRunning() {
|
|
204
|
-
if (!this.state.isRunning || !this.state.pid) {
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
// Verify that the process is actually alive
|
|
208
|
-
if (!this.isProcessAlive(this.state.pid)) {
|
|
209
|
-
logger.warn(`[ProcessManager] Process PID=${this.state.pid} appears dead, cleaning up`);
|
|
210
|
-
this.cleanup();
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
return true;
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Get the process ID of the running server
|
|
217
|
-
*/
|
|
218
|
-
getPID() {
|
|
219
|
-
return this.state.pid;
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Get the uptime of the server in milliseconds
|
|
223
|
-
*/
|
|
224
|
-
getUptime() {
|
|
225
|
-
if (!this.state.startTime || !this.state.isRunning) {
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
return Date.now() - this.state.startTime.getTime();
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Check if a process with given PID is alive
|
|
232
|
-
* Uses process.kill(pid, 0) which checks existence without killing
|
|
233
|
-
*/
|
|
234
|
-
isProcessAlive(pid) {
|
|
235
|
-
try {
|
|
236
|
-
process.kill(pid, 0);
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
return false;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Wait for process to exit
|
|
245
|
-
*/
|
|
246
|
-
async waitForProcessExit(childProcess, timeoutMs) {
|
|
247
|
-
return new Promise((resolve) => {
|
|
248
|
-
const exitHandler = () => {
|
|
249
|
-
logger.debug("[ProcessManager] Process exited gracefully");
|
|
250
|
-
resolve(true);
|
|
251
|
-
};
|
|
252
|
-
childProcess.once("exit", exitHandler);
|
|
253
|
-
setTimeout(() => {
|
|
254
|
-
childProcess.removeListener("exit", exitHandler);
|
|
255
|
-
resolve(false);
|
|
256
|
-
}, timeoutMs);
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* Clean up state and settings
|
|
261
|
-
*/
|
|
262
|
-
cleanup() {
|
|
263
|
-
this.state = {
|
|
264
|
-
process: null,
|
|
265
|
-
pid: null,
|
|
266
|
-
startTime: null,
|
|
267
|
-
isRunning: false,
|
|
268
|
-
};
|
|
269
|
-
clearServerProcess();
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
// Export singleton instance
|
|
273
|
-
export const processManager = new ProcessManager();
|
|
File without changes
|