@love-moon/conductor-cli 0.2.19 → 0.2.21
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/bin/conductor-channel.js +130 -0
- package/bin/conductor-config.js +1 -1
- package/bin/conductor-daemon.js +51 -0
- package/bin/conductor-diagnose.js +25 -0
- package/bin/conductor-fire.js +230 -1
- package/bin/conductor-update.js +15 -110
- package/bin/conductor.js +77 -52
- package/package.json +11 -4
- package/src/cli-update-notifier.js +241 -0
- package/src/daemon.js +1006 -32
- package/src/version-check.js +240 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import yargs from "yargs/yargs";
|
|
10
|
+
import { hideBin } from "yargs/helpers";
|
|
11
|
+
import { loadConfig } from "@love-moon/conductor-sdk";
|
|
12
|
+
|
|
13
|
+
const isMainModule = (() => {
|
|
14
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
15
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
16
|
+
return entryFile === currentFile;
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
function resolveDefaultConfigPath(env = process.env) {
|
|
20
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
21
|
+
return path.join(home, ".conductor", "config.yaml");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readTextFile(filePath) {
|
|
25
|
+
return fs.readFileSync(filePath, "utf8");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureFeishuChannelConfig(config) {
|
|
29
|
+
const feishu = config?.channels?.feishu;
|
|
30
|
+
if (!feishu?.appId || !feishu?.appSecret || !feishu?.verificationToken) {
|
|
31
|
+
throw new Error("config.yaml is missing channels.feishu.app_id/app_secret/verification_token");
|
|
32
|
+
}
|
|
33
|
+
return feishu;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatErrorBody(text) {
|
|
37
|
+
const normalized = typeof text === "string" ? text.trim() : "";
|
|
38
|
+
if (!normalized) return "";
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(normalized);
|
|
41
|
+
if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
|
|
42
|
+
return parsed.error;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function connectFeishuChannel(options = {}) {
|
|
51
|
+
const env = options.env ?? process.env;
|
|
52
|
+
const fetchImpl = options.fetchImpl ?? global.fetch;
|
|
53
|
+
if (typeof fetchImpl !== "function") {
|
|
54
|
+
throw new Error("fetch is not available");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolvedConfigPath = path.resolve(options.configFile || resolveDefaultConfigPath(env));
|
|
58
|
+
const rawYaml = readTextFile(resolvedConfigPath);
|
|
59
|
+
const config = loadConfig(resolvedConfigPath, { env });
|
|
60
|
+
const feishu = ensureFeishuChannelConfig(config);
|
|
61
|
+
|
|
62
|
+
const url = new URL("/api/channel/feishu/config", config.backendUrl);
|
|
63
|
+
const response = await fetchImpl(String(url), {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${config.agentToken}`,
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
Accept: "application/json",
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({ yaml: rawYaml }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const rawText = await response.text();
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const detail = formatErrorBody(rawText);
|
|
76
|
+
throw new Error(`Failed to connect Feishu channel (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = rawText ? JSON.parse(rawText) : {};
|
|
80
|
+
return {
|
|
81
|
+
configPath: resolvedConfigPath,
|
|
82
|
+
feishu,
|
|
83
|
+
config: parsed.config ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function main(argvInput = hideBin(process.argv), dependencies = {}) {
|
|
88
|
+
const log = dependencies.log ?? console.log;
|
|
89
|
+
const logError = dependencies.logError ?? console.error;
|
|
90
|
+
|
|
91
|
+
await yargs(argvInput)
|
|
92
|
+
.scriptName("conductor channel")
|
|
93
|
+
.command(
|
|
94
|
+
"connect feishu",
|
|
95
|
+
"Upload channels.feishu from config.yaml to Conductor backend",
|
|
96
|
+
(command) => command.option("config-file", {
|
|
97
|
+
type: "string",
|
|
98
|
+
describe: "Path to Conductor config file",
|
|
99
|
+
}),
|
|
100
|
+
async (argv) => {
|
|
101
|
+
try {
|
|
102
|
+
const result = await connectFeishuChannel({
|
|
103
|
+
configFile: argv.configFile,
|
|
104
|
+
});
|
|
105
|
+
const effective = result.config ?? {
|
|
106
|
+
provider: "FEISHU",
|
|
107
|
+
appId: result.feishu.appId,
|
|
108
|
+
verificationToken: result.feishu.verificationToken,
|
|
109
|
+
};
|
|
110
|
+
log(`Connected Feishu channel from ${result.configPath}`);
|
|
111
|
+
log(`app_id: ${effective.appId}`);
|
|
112
|
+
log(`verification_token: ${effective.verificationToken}`);
|
|
113
|
+
log("Configure the same verification_token in Feishu Open Platform webhook settings.");
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
.demandCommand(1)
|
|
121
|
+
.help()
|
|
122
|
+
.parseAsync();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isMainModule) {
|
|
126
|
+
main().catch((error) => {
|
|
127
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
|
130
|
+
}
|
package/bin/conductor-config.js
CHANGED
|
@@ -23,7 +23,7 @@ const DEFAULT_CLIs = {
|
|
|
23
23
|
},
|
|
24
24
|
codex: {
|
|
25
25
|
command: "codex",
|
|
26
|
-
execArgs: "
|
|
26
|
+
execArgs: "--dangerously-bypass-approvals-and-sandbox --ask-for-approval never",
|
|
27
27
|
description: "OpenAI Codex CLI"
|
|
28
28
|
},
|
|
29
29
|
// gemini: {
|
package/bin/conductor-daemon.js
CHANGED
|
@@ -14,6 +14,56 @@ const argv = hideBin(process.argv);
|
|
|
14
14
|
|
|
15
15
|
const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor-daemon";
|
|
16
16
|
|
|
17
|
+
function parseJsonArrayEnv(value) {
|
|
18
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(value);
|
|
23
|
+
if (!Array.isArray(parsed)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return parsed.filter((entry) => typeof entry === "string");
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stripNohupArgs(args) {
|
|
33
|
+
return args.filter((arg) => !(arg === "--nohup" || arg.startsWith("--nohup=")));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveLauncherConfig() {
|
|
37
|
+
const inheritedLauncherScript =
|
|
38
|
+
typeof process.env.CONDUCTOR_LAUNCHER_SCRIPT === "string" &&
|
|
39
|
+
process.env.CONDUCTOR_LAUNCHER_SCRIPT.trim()
|
|
40
|
+
? process.env.CONDUCTOR_LAUNCHER_SCRIPT.trim()
|
|
41
|
+
: null;
|
|
42
|
+
const inheritedSubcommand =
|
|
43
|
+
typeof process.env.CONDUCTOR_SUBCOMMAND === "string" &&
|
|
44
|
+
process.env.CONDUCTOR_SUBCOMMAND.trim()
|
|
45
|
+
? process.env.CONDUCTOR_SUBCOMMAND.trim()
|
|
46
|
+
: null;
|
|
47
|
+
const inheritedSubcommandArgs = parseJsonArrayEnv(process.env.CONDUCTOR_SUBCOMMAND_ARGS_JSON);
|
|
48
|
+
|
|
49
|
+
if (inheritedLauncherScript && inheritedSubcommand === "daemon" && inheritedSubcommandArgs) {
|
|
50
|
+
return {
|
|
51
|
+
restartLauncherScript: inheritedLauncherScript,
|
|
52
|
+
restartLauncherArgs: ["daemon", ...stripNohupArgs(inheritedSubcommandArgs)],
|
|
53
|
+
versionCheckScript: inheritedLauncherScript,
|
|
54
|
+
versionCheckArgs: ["--version"],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const daemonScript = path.resolve(process.argv[1]);
|
|
59
|
+
return {
|
|
60
|
+
restartLauncherScript: daemonScript,
|
|
61
|
+
restartLauncherArgs: argv,
|
|
62
|
+
versionCheckScript: inheritedLauncherScript,
|
|
63
|
+
versionCheckArgs: ["--version"],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
17
67
|
function formatBeijingTimestampForFile(date = new Date()) {
|
|
18
68
|
const base = date
|
|
19
69
|
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
|
|
@@ -149,4 +199,5 @@ startDaemon({
|
|
|
149
199
|
CLEAN_ALL: args.cleanAll,
|
|
150
200
|
CONFIG_FILE: args.configFile,
|
|
151
201
|
FORCE: args.force,
|
|
202
|
+
...resolveLauncherConfig(),
|
|
152
203
|
});
|
|
@@ -244,6 +244,7 @@ function printReport(taskId, report) {
|
|
|
244
244
|
const diagnosis = payload?.diagnosis || {};
|
|
245
245
|
const task = payload?.task || {};
|
|
246
246
|
const realtime = payload?.realtime || {};
|
|
247
|
+
const ptyTransport = payload?.pty_transport || payload?.ptyTransport || {};
|
|
247
248
|
const messages = payload?.messages || {};
|
|
248
249
|
|
|
249
250
|
process.stdout.write(`Task: ${task?.id || taskId}\n`);
|
|
@@ -273,6 +274,25 @@ function printReport(taskId, report) {
|
|
|
273
274
|
);
|
|
274
275
|
}
|
|
275
276
|
}
|
|
277
|
+
const latestLatency = ptyTransport?.latest_latency_sample || ptyTransport?.latestLatencySample;
|
|
278
|
+
if (latestLatency) {
|
|
279
|
+
process.stdout.write(
|
|
280
|
+
`- pty.latest_latency: client->server=${formatLatencyMs(
|
|
281
|
+
latestLatency.client_input_to_server_received_ms ?? latestLatency.clientInputToServerReceivedMs,
|
|
282
|
+
)}, server->daemon=${formatLatencyMs(
|
|
283
|
+
latestLatency.server_received_to_daemon_received_ms ?? latestLatency.serverReceivedToDaemonReceivedMs,
|
|
284
|
+
)}, daemon->first_output=${formatLatencyMs(
|
|
285
|
+
latestLatency.daemon_input_to_first_output_ms ?? latestLatency.daemonInputToFirstOutputMs,
|
|
286
|
+
)}, client->first_output=${formatLatencyMs(
|
|
287
|
+
latestLatency.client_input_to_first_output_ms ?? latestLatency.clientInputToFirstOutputMs,
|
|
288
|
+
)}\n`,
|
|
289
|
+
);
|
|
290
|
+
process.stdout.write(
|
|
291
|
+
`- pty.primary_bottleneck: ${String(
|
|
292
|
+
ptyTransport?.primary_bottleneck || ptyTransport?.primaryBottleneck || "unknown",
|
|
293
|
+
)}\n`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
276
296
|
|
|
277
297
|
const reasons = Array.isArray(diagnosis.reasons) ? diagnosis.reasons : [];
|
|
278
298
|
if (reasons.length > 0) {
|
|
@@ -364,6 +384,11 @@ function detectExecutionFailureLoopKey(value) {
|
|
|
364
384
|
return null;
|
|
365
385
|
}
|
|
366
386
|
|
|
387
|
+
function formatLatencyMs(value) {
|
|
388
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
389
|
+
return Number.isFinite(num) && num >= 0 ? `${Math.round(num)}ms` : "n/a";
|
|
390
|
+
}
|
|
391
|
+
|
|
367
392
|
function normalizePositiveInt(value, fallback) {
|
|
368
393
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
369
394
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
package/bin/conductor-fire.js
CHANGED
|
@@ -42,6 +42,12 @@ const CLI_NAME = (process.env.CONDUCTOR_CLI_NAME || path.basename(process.argv[1
|
|
|
42
42
|
"",
|
|
43
43
|
);
|
|
44
44
|
|
|
45
|
+
export function buildConductorConnectHeaders(version = pkgJson.version) {
|
|
46
|
+
return {
|
|
47
|
+
"x-conductor-version": version,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
// Load allow_cli_list from config file (no defaults - must be configured)
|
|
46
52
|
function loadAllowCliList(configFilePath) {
|
|
47
53
|
try {
|
|
@@ -98,6 +104,30 @@ const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
|
|
|
98
104
|
const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
|
|
99
105
|
const SESSION_BOOTSTRAP_LOCK_TIMEOUT_MS = 15_000;
|
|
100
106
|
const SESSION_BOOTSTRAP_LOCK_RETRY_MS = 50;
|
|
107
|
+
const FIRE_WATCHDOG_INTERVAL_MS = getBoundedEnvInt(
|
|
108
|
+
"CONDUCTOR_FIRE_WATCHDOG_INTERVAL_MS",
|
|
109
|
+
10_000,
|
|
110
|
+
1_000,
|
|
111
|
+
60_000,
|
|
112
|
+
);
|
|
113
|
+
const FIRE_WATCHDOG_STALE_WS_MS = getBoundedEnvInt(
|
|
114
|
+
"CONDUCTOR_FIRE_WATCHDOG_STALE_WS_MS",
|
|
115
|
+
45_000,
|
|
116
|
+
5_000,
|
|
117
|
+
5 * 60_000,
|
|
118
|
+
);
|
|
119
|
+
const FIRE_WATCHDOG_CONNECT_GRACE_MS = getBoundedEnvInt(
|
|
120
|
+
"CONDUCTOR_FIRE_WATCHDOG_CONNECT_GRACE_MS",
|
|
121
|
+
15_000,
|
|
122
|
+
1_000,
|
|
123
|
+
60_000,
|
|
124
|
+
);
|
|
125
|
+
const FIRE_WATCHDOG_RECONNECT_COOLDOWN_MS = getBoundedEnvInt(
|
|
126
|
+
"CONDUCTOR_FIRE_WATCHDOG_RECONNECT_COOLDOWN_MS",
|
|
127
|
+
15_000,
|
|
128
|
+
1_000,
|
|
129
|
+
2 * 60_000,
|
|
130
|
+
);
|
|
101
131
|
|
|
102
132
|
function sleepSync(ms) {
|
|
103
133
|
if (!Number.isFinite(ms) || ms <= 0) {
|
|
@@ -191,6 +221,176 @@ function appendFireLocalLog(line) {
|
|
|
191
221
|
}
|
|
192
222
|
}
|
|
193
223
|
|
|
224
|
+
function formatFireDisconnectDiagnostics(event = {}) {
|
|
225
|
+
const parts = [];
|
|
226
|
+
if (event.reason) {
|
|
227
|
+
parts.push(`reason=${event.reason}`);
|
|
228
|
+
}
|
|
229
|
+
if (typeof event.closeCode === "number") {
|
|
230
|
+
parts.push(`close_code=${event.closeCode}`);
|
|
231
|
+
}
|
|
232
|
+
if (event.closeReason) {
|
|
233
|
+
parts.push(`close_reason=${sanitizeForLog(event.closeReason, 120)}`);
|
|
234
|
+
}
|
|
235
|
+
if (event.socketError) {
|
|
236
|
+
parts.push(`socket_error=${sanitizeForLog(event.socketError, 120)}`);
|
|
237
|
+
}
|
|
238
|
+
if (typeof event.missedPongs === "number" && event.missedPongs > 0) {
|
|
239
|
+
parts.push(`missed_pongs=${event.missedPongs}`);
|
|
240
|
+
}
|
|
241
|
+
return parts.join(" ") || "reason=connection_lost";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatIsoTimestamp(value) {
|
|
245
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
246
|
+
return "n/a";
|
|
247
|
+
}
|
|
248
|
+
return new Date(value).toISOString();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatFireWatchdogState({ connectedAt, lastPongAt, lastInboundAt }) {
|
|
252
|
+
return [
|
|
253
|
+
`connected_at=${formatIsoTimestamp(connectedAt)}`,
|
|
254
|
+
`last_pong_at=${formatIsoTimestamp(lastPongAt)}`,
|
|
255
|
+
`last_inbound_at=${formatIsoTimestamp(lastInboundAt)}`,
|
|
256
|
+
].join(" ");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export class FireWatchdog {
|
|
260
|
+
constructor({
|
|
261
|
+
intervalMs = FIRE_WATCHDOG_INTERVAL_MS,
|
|
262
|
+
staleWsMs = FIRE_WATCHDOG_STALE_WS_MS,
|
|
263
|
+
connectGraceMs = FIRE_WATCHDOG_CONNECT_GRACE_MS,
|
|
264
|
+
reconnectCooldownMs = FIRE_WATCHDOG_RECONNECT_COOLDOWN_MS,
|
|
265
|
+
onForceReconnect,
|
|
266
|
+
logger = () => {},
|
|
267
|
+
now = () => Date.now(),
|
|
268
|
+
} = {}) {
|
|
269
|
+
this.intervalMs = intervalMs;
|
|
270
|
+
this.staleWsMs = staleWsMs;
|
|
271
|
+
this.connectGraceMs = connectGraceMs;
|
|
272
|
+
this.reconnectCooldownMs = reconnectCooldownMs;
|
|
273
|
+
this.onForceReconnect = onForceReconnect;
|
|
274
|
+
this.logger = logger;
|
|
275
|
+
this.now = now;
|
|
276
|
+
this.wsConnected = false;
|
|
277
|
+
this.lastConnectedAt = null;
|
|
278
|
+
this.lastPongAt = null;
|
|
279
|
+
this.lastInboundAt = null;
|
|
280
|
+
this.lastHealAt = 0;
|
|
281
|
+
this.healAttempts = 0;
|
|
282
|
+
this.awaitingHealthySignalAt = null;
|
|
283
|
+
this.timer = null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
start() {
|
|
287
|
+
if (this.timer) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.timer = setInterval(() => {
|
|
291
|
+
void this.runOnce();
|
|
292
|
+
}, this.intervalMs);
|
|
293
|
+
if (typeof this.timer.unref === "function") {
|
|
294
|
+
this.timer.unref();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
stop() {
|
|
299
|
+
if (!this.timer) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
clearInterval(this.timer);
|
|
303
|
+
this.timer = null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
onConnected({ isReconnect = false, connectedAt = this.now() } = {}) {
|
|
307
|
+
this.wsConnected = true;
|
|
308
|
+
this.lastConnectedAt = connectedAt;
|
|
309
|
+
this.lastPongAt =
|
|
310
|
+
Number.isFinite(this.lastPongAt) && this.lastPongAt > connectedAt ? this.lastPongAt : connectedAt;
|
|
311
|
+
if (isReconnect && this.healAttempts > 0) {
|
|
312
|
+
this.awaitingHealthySignalAt = connectedAt;
|
|
313
|
+
} else if (!isReconnect) {
|
|
314
|
+
this.awaitingHealthySignalAt = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
onDisconnected() {
|
|
319
|
+
this.wsConnected = false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
onPong({ at = this.now() } = {}) {
|
|
323
|
+
this.lastPongAt = at;
|
|
324
|
+
this.markHealthy("pong", at);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
onInbound(at = this.now()) {
|
|
328
|
+
this.lastInboundAt = at;
|
|
329
|
+
this.markHealthy("inbound", at);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
markHealthy(signal, at = this.now()) {
|
|
333
|
+
if (!this.awaitingHealthySignalAt || this.healAttempts === 0) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (at < this.awaitingHealthySignalAt) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.logger(
|
|
340
|
+
`[watchdog] Backend websocket healthy again after self-heal via ${signal} (${formatFireWatchdogState({
|
|
341
|
+
connectedAt: this.lastConnectedAt,
|
|
342
|
+
lastPongAt: this.lastPongAt,
|
|
343
|
+
lastInboundAt: this.lastInboundAt,
|
|
344
|
+
})})`,
|
|
345
|
+
);
|
|
346
|
+
this.awaitingHealthySignalAt = null;
|
|
347
|
+
this.healAttempts = 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async runOnce() {
|
|
351
|
+
if (!this.wsConnected || typeof this.onForceReconnect !== "function") {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
const now = this.now();
|
|
355
|
+
if (!Number.isFinite(this.lastConnectedAt) || now - this.lastConnectedAt < this.connectGraceMs) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
const lastWsHealthAt = Math.max(this.lastPongAt || 0, this.lastInboundAt || 0, this.lastConnectedAt || 0);
|
|
359
|
+
if (lastWsHealthAt && now - lastWsHealthAt <= this.staleWsMs) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (this.lastHealAt && now - this.lastHealAt < this.reconnectCooldownMs) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.lastHealAt = now;
|
|
367
|
+
this.healAttempts += 1;
|
|
368
|
+
this.awaitingHealthySignalAt = null;
|
|
369
|
+
this.wsConnected = false;
|
|
370
|
+
this.logger(
|
|
371
|
+
`[watchdog] stale_ws_health; restarting fire websocket (${this.healAttempts}) (${formatFireWatchdogState({
|
|
372
|
+
connectedAt: this.lastConnectedAt,
|
|
373
|
+
lastPongAt: this.lastPongAt,
|
|
374
|
+
lastInboundAt: this.lastInboundAt,
|
|
375
|
+
})})`,
|
|
376
|
+
);
|
|
377
|
+
await this.onForceReconnect("watchdog:stale_ws_health");
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
getDebugState() {
|
|
382
|
+
return {
|
|
383
|
+
wsConnected: this.wsConnected,
|
|
384
|
+
lastConnectedAt: this.lastConnectedAt,
|
|
385
|
+
lastPongAt: this.lastPongAt,
|
|
386
|
+
lastInboundAt: this.lastInboundAt,
|
|
387
|
+
lastHealAt: this.lastHealAt,
|
|
388
|
+
healAttempts: this.healAttempts,
|
|
389
|
+
awaitingHealthySignalAt: this.awaitingHealthySignalAt,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
194
394
|
async function main() {
|
|
195
395
|
const cliArgs = parseCliArgs();
|
|
196
396
|
let runtimeProjectPath = process.cwd();
|
|
@@ -239,6 +439,17 @@ async function main() {
|
|
|
239
439
|
let pendingRemoteStopEvent = null;
|
|
240
440
|
let conductor = null;
|
|
241
441
|
let reconnectResumeInFlight = false;
|
|
442
|
+
let fireShuttingDown = false;
|
|
443
|
+
const fireWatchdog = new FireWatchdog({
|
|
444
|
+
onForceReconnect: async (reason) => {
|
|
445
|
+
if (!conductor || typeof conductor.forceReconnect !== "function") {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
await conductor.forceReconnect(reason);
|
|
449
|
+
},
|
|
450
|
+
logger: log,
|
|
451
|
+
});
|
|
452
|
+
fireWatchdog.start();
|
|
242
453
|
|
|
243
454
|
const scheduleReconnectRecovery = ({ isReconnect }) => {
|
|
244
455
|
if (!isReconnect) {
|
|
@@ -274,6 +485,7 @@ async function main() {
|
|
|
274
485
|
};
|
|
275
486
|
|
|
276
487
|
const handleStopTaskCommand = async (event) => {
|
|
488
|
+
fireWatchdog.onInbound();
|
|
277
489
|
if (!event || typeof event !== "object") {
|
|
278
490
|
return;
|
|
279
491
|
}
|
|
@@ -321,8 +533,21 @@ async function main() {
|
|
|
321
533
|
conductor = await ConductorClient.connect({
|
|
322
534
|
projectPath: runtimeProjectPath,
|
|
323
535
|
extraEnv: env,
|
|
536
|
+
extraHeaders: buildConductorConnectHeaders(),
|
|
324
537
|
configFile: cliArgs.configFile,
|
|
325
|
-
onConnected:
|
|
538
|
+
onConnected: (event) => {
|
|
539
|
+
fireWatchdog.onConnected(event);
|
|
540
|
+
scheduleReconnectRecovery(event);
|
|
541
|
+
},
|
|
542
|
+
onDisconnected: (event) => {
|
|
543
|
+
fireWatchdog.onDisconnected();
|
|
544
|
+
if (!fireShuttingDown) {
|
|
545
|
+
log(`[fire-ws] Disconnected from backend: ${formatFireDisconnectDiagnostics(event)}`);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
onPong: (event) => {
|
|
549
|
+
fireWatchdog.onPong(event);
|
|
550
|
+
},
|
|
326
551
|
onStopTask: handleStopTaskCommand,
|
|
327
552
|
});
|
|
328
553
|
|
|
@@ -425,11 +650,13 @@ async function main() {
|
|
|
425
650
|
};
|
|
426
651
|
const onSigint = () => {
|
|
427
652
|
shutdownSignal = shutdownSignal || "SIGINT";
|
|
653
|
+
fireShuttingDown = true;
|
|
428
654
|
signals.abort();
|
|
429
655
|
requestBackendShutdown("SIGINT");
|
|
430
656
|
};
|
|
431
657
|
const onSigterm = () => {
|
|
432
658
|
shutdownSignal = shutdownSignal || "SIGTERM";
|
|
659
|
+
fireShuttingDown = true;
|
|
433
660
|
signals.abort();
|
|
434
661
|
requestBackendShutdown("SIGTERM");
|
|
435
662
|
};
|
|
@@ -494,6 +721,8 @@ async function main() {
|
|
|
494
721
|
}
|
|
495
722
|
}
|
|
496
723
|
} finally {
|
|
724
|
+
fireShuttingDown = true;
|
|
725
|
+
fireWatchdog.stop();
|
|
497
726
|
if (backendSession && typeof backendSession.close === "function") {
|
|
498
727
|
try {
|
|
499
728
|
await backendSession.close();
|
package/bin/conductor-update.js
CHANGED
|
@@ -8,9 +8,15 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
import fs from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
12
|
import process from "node:process";
|
|
13
13
|
import readline from "node:readline/promises";
|
|
14
|
+
import {
|
|
15
|
+
PACKAGE_NAME,
|
|
16
|
+
fetchLatestVersion,
|
|
17
|
+
isNewerVersion,
|
|
18
|
+
detectPackageManager,
|
|
19
|
+
} from "../src/version-check.js";
|
|
14
20
|
|
|
15
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
22
|
const __dirname = path.dirname(__filename);
|
|
@@ -18,7 +24,6 @@ const require = createRequire(import.meta.url);
|
|
|
18
24
|
const PKG_ROOT = path.join(__dirname, "..");
|
|
19
25
|
|
|
20
26
|
const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
|
|
21
|
-
const PACKAGE_NAME = pkgJson.name;
|
|
22
27
|
const CURRENT_VERSION = pkgJson.version;
|
|
23
28
|
|
|
24
29
|
// ANSI 颜色代码
|
|
@@ -110,75 +115,11 @@ async function main() {
|
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
async function getLatestVersion() {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const result = execSync(`npm view ${PACKAGE_NAME} version --json`, {
|
|
117
|
-
encoding: "utf-8",
|
|
118
|
-
timeout: 10000,
|
|
119
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// npm view 返回的是带引号的字符串 JSON
|
|
123
|
-
const version = JSON.parse(result.trim());
|
|
124
|
-
resolve(version);
|
|
125
|
-
} catch (error) {
|
|
126
|
-
// 如果失败,尝试从 registry API 获取
|
|
127
|
-
fetchLatestFromRegistry()
|
|
128
|
-
.then(resolve)
|
|
129
|
-
.catch(reject);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function fetchLatestFromRegistry() {
|
|
135
|
-
return new Promise((resolve, reject) => {
|
|
136
|
-
const https = require("https");
|
|
137
|
-
const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
138
|
-
|
|
139
|
-
https.get(url, { timeout: 10000 }, (res) => {
|
|
140
|
-
let data = "";
|
|
141
|
-
|
|
142
|
-
res.on("data", (chunk) => {
|
|
143
|
-
data += chunk;
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
res.on("end", () => {
|
|
147
|
-
try {
|
|
148
|
-
const json = JSON.parse(data);
|
|
149
|
-
if (json.version) {
|
|
150
|
-
resolve(json.version);
|
|
151
|
-
} else {
|
|
152
|
-
reject(new Error("Invalid response from registry"));
|
|
153
|
-
}
|
|
154
|
-
} catch (error) {
|
|
155
|
-
reject(new Error(`Failed to parse registry response: ${error.message}`));
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
}).on("error", (error) => {
|
|
159
|
-
reject(new Error(`Network error: ${error.message}`));
|
|
160
|
-
}).on("timeout", () => {
|
|
161
|
-
reject(new Error("Request timed out"));
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function isNewerVersion(latest, current) {
|
|
167
|
-
// 简单的版本比较
|
|
168
|
-
const parseVersion = (v) => v.replace(/^v/, "").split(".").map(Number);
|
|
169
|
-
|
|
170
|
-
const latestParts = parseVersion(latest);
|
|
171
|
-
const currentParts = parseVersion(current);
|
|
172
|
-
|
|
173
|
-
for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
|
|
174
|
-
const l = latestParts[i] || 0;
|
|
175
|
-
const c = currentParts[i] || 0;
|
|
176
|
-
|
|
177
|
-
if (l > c) return true;
|
|
178
|
-
if (l < c) return false;
|
|
118
|
+
const version = await fetchLatestVersion();
|
|
119
|
+
if (!version) {
|
|
120
|
+
throw new Error("Could not fetch latest version from npm registry");
|
|
179
121
|
}
|
|
180
|
-
|
|
181
|
-
return false; // 版本相同
|
|
122
|
+
return version;
|
|
182
123
|
}
|
|
183
124
|
|
|
184
125
|
async function confirmUpdate(version) {
|
|
@@ -201,7 +142,10 @@ async function confirmUpdate(version) {
|
|
|
201
142
|
async function performUpdate() {
|
|
202
143
|
return new Promise((resolve, reject) => {
|
|
203
144
|
// 检测使用的包管理器
|
|
204
|
-
const packageManager = detectPackageManager(
|
|
145
|
+
const packageManager = detectPackageManager({
|
|
146
|
+
launcherPath: process.env.CONDUCTOR_LAUNCHER_SCRIPT || process.argv[1],
|
|
147
|
+
packageRoot: PKG_ROOT,
|
|
148
|
+
});
|
|
205
149
|
console.log(` Using package manager: ${colorize(packageManager, "cyan")}`);
|
|
206
150
|
console.log("");
|
|
207
151
|
|
|
@@ -245,45 +189,6 @@ async function performUpdate() {
|
|
|
245
189
|
});
|
|
246
190
|
}
|
|
247
191
|
|
|
248
|
-
function detectPackageManager() {
|
|
249
|
-
// 通过分析 conductor 命令的路径来推断包管理器
|
|
250
|
-
try {
|
|
251
|
-
const conductorPath = execSync("which conductor || where conductor", {
|
|
252
|
-
encoding: "utf-8",
|
|
253
|
-
stdio: ["pipe", "pipe", "ignore"]
|
|
254
|
-
}).trim();
|
|
255
|
-
|
|
256
|
-
if (conductorPath.includes("pnpm")) {
|
|
257
|
-
return "pnpm";
|
|
258
|
-
}
|
|
259
|
-
if (conductorPath.includes("yarn")) {
|
|
260
|
-
return "yarn";
|
|
261
|
-
}
|
|
262
|
-
if (conductorPath.includes(".npm") || conductorPath.includes("npm")) {
|
|
263
|
-
return "npm";
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// 忽略错误,使用默认检测
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// 检查哪个包管理器可用
|
|
270
|
-
try {
|
|
271
|
-
execSync("pnpm --version", { stdio: "pipe" });
|
|
272
|
-
return "pnpm";
|
|
273
|
-
} catch {
|
|
274
|
-
// pnpm 不可用
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
execSync("yarn --version", { stdio: "pipe" });
|
|
279
|
-
return "yarn";
|
|
280
|
-
} catch {
|
|
281
|
-
// yarn 不可用
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return "npm"; // 默认使用 npm
|
|
285
|
-
}
|
|
286
|
-
|
|
287
192
|
function showHelpMessage() {
|
|
288
193
|
console.log(`conductor update - Update the CLI to the latest version
|
|
289
194
|
|