@songsid/agend 2.1.2-beta.47 → 2.1.2-beta.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/kiro.js +35 -3
- package/dist/backend/kiro.js.map +1 -1
- package/dist/daemon.js +18 -3
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-manager.d.ts +9 -0
- package/dist/fleet-manager.js +164 -50
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +2 -0
- package/dist/locale.js.map +1 -1
- package/dist/restart-progress.d.ts +34 -0
- package/dist/restart-progress.js +87 -0
- package/dist/restart-progress.js.map +1 -0
- package/dist/topic-commands.js +5 -6
- package/dist/topic-commands.js.map +1 -1
- package/dist/web-api.js +2 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +6 -0
- package/dist/web-auth.js +54 -0
- package/dist/web-auth.js.map +1 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -51,6 +51,8 @@ import { readLastInboundAt } from "./daemon.js";
|
|
|
51
51
|
import { clearPausedMarker } from "./pause-marker.js";
|
|
52
52
|
import { releaseProcessFleetLock } from "./fleet-lock.js";
|
|
53
53
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
54
|
+
import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
|
|
55
|
+
import { RestartProgress } from "./restart-progress.js";
|
|
54
56
|
import { getTmuxSession } from "./config.js";
|
|
55
57
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
56
58
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -285,6 +287,7 @@ export class FleetManager {
|
|
|
285
287
|
sseClients = new Set();
|
|
286
288
|
webToken = null;
|
|
287
289
|
viewToken = null;
|
|
290
|
+
healthServerListening = false;
|
|
288
291
|
constructor(dataDir) {
|
|
289
292
|
this.dataDir = dataDir;
|
|
290
293
|
FleetManager.signalTarget = this;
|
|
@@ -1059,7 +1062,18 @@ export class FleetManager {
|
|
|
1059
1062
|
* to avoid config file races. Stagger delay is group-to-group, not instance-to-instance.
|
|
1060
1063
|
* TODO: per-instance startup timeout (existing issue, not introduced here)
|
|
1061
1064
|
*/
|
|
1062
|
-
async startInstancesWithConcurrency(entries, topicMode) {
|
|
1065
|
+
async startInstancesWithConcurrency(entries, topicMode, onReady) {
|
|
1066
|
+
// Persisted pauses are intentionally preserved across fleet restarts. Filter
|
|
1067
|
+
// them before grouping/staggering: startInstance() retains its own guard as
|
|
1068
|
+
// a final backstop, but putting a no-op entry in this queue still consumes a
|
|
1069
|
+
// full stagger slot for every distinct working directory.
|
|
1070
|
+
const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
|
|
1071
|
+
const pausedCount = entries.length - runnableEntries.length;
|
|
1072
|
+
if (pausedCount > 0) {
|
|
1073
|
+
this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
|
|
1074
|
+
}
|
|
1075
|
+
if (runnableEntries.length === 0)
|
|
1076
|
+
return;
|
|
1063
1077
|
const raw = this.fleetConfig?.defaults?.startup;
|
|
1064
1078
|
const explicitConcurrency = raw?.concurrency;
|
|
1065
1079
|
const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500));
|
|
@@ -1074,10 +1088,10 @@ export class FleetManager {
|
|
|
1074
1088
|
else {
|
|
1075
1089
|
const freeMemMB = Math.round(freemem() / (1024 * 1024));
|
|
1076
1090
|
concurrency = Math.max(2, Math.min(10, Math.floor(freeMemMB / ESTIMATED_MB_PER_INSTANCE)));
|
|
1077
|
-
this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances:
|
|
1091
|
+
this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: runnableEntries.length }, "Adaptive startup concurrency");
|
|
1078
1092
|
}
|
|
1079
1093
|
const byWorkDir = new Map();
|
|
1080
|
-
for (const [name, config] of
|
|
1094
|
+
for (const [name, config] of runnableEntries) {
|
|
1081
1095
|
const dir = config.working_directory;
|
|
1082
1096
|
if (!byWorkDir.has(dir))
|
|
1083
1097
|
byWorkDir.set(dir, []);
|
|
@@ -1120,7 +1134,14 @@ export class FleetManager {
|
|
|
1120
1134
|
lastStartAt = Date.now();
|
|
1121
1135
|
(async () => {
|
|
1122
1136
|
for (const [name, config] of group) {
|
|
1123
|
-
|
|
1137
|
+
try {
|
|
1138
|
+
await this.startInstance(name, config, topicMode);
|
|
1139
|
+
if (this.daemons.has(name))
|
|
1140
|
+
onReady?.(name);
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
this.logger.error({ err, name }, "Failed to start instance");
|
|
1144
|
+
}
|
|
1124
1145
|
}
|
|
1125
1146
|
})().finally(() => {
|
|
1126
1147
|
running--;
|
|
@@ -1134,6 +1155,34 @@ export class FleetManager {
|
|
|
1134
1155
|
startNext();
|
|
1135
1156
|
});
|
|
1136
1157
|
}
|
|
1158
|
+
runnableStartupCount(fleet, includeClassic) {
|
|
1159
|
+
const names = new Set(Object.keys(fleet.instances));
|
|
1160
|
+
if (includeClassic) {
|
|
1161
|
+
for (const channel of this.classicChannels?.getAll() ?? [])
|
|
1162
|
+
names.add(channel.instanceName);
|
|
1163
|
+
}
|
|
1164
|
+
let count = 0;
|
|
1165
|
+
for (const name of names) {
|
|
1166
|
+
if (!this.lifecycle.isPaused(name))
|
|
1167
|
+
count++;
|
|
1168
|
+
}
|
|
1169
|
+
return count;
|
|
1170
|
+
}
|
|
1171
|
+
restartProgressTarget() {
|
|
1172
|
+
const generalName = this.findGeneralInstance();
|
|
1173
|
+
if (!generalName)
|
|
1174
|
+
return null;
|
|
1175
|
+
const adapter = this.getAdapterForInstance(generalName);
|
|
1176
|
+
const chatId = this.getGroupIdForInstance(generalName);
|
|
1177
|
+
if (!adapter || !chatId)
|
|
1178
|
+
return null;
|
|
1179
|
+
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
1180
|
+
return {
|
|
1181
|
+
adapter,
|
|
1182
|
+
chatId,
|
|
1183
|
+
threadId: topicId != null ? String(topicId) : undefined,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1137
1186
|
async stopInstance(name) {
|
|
1138
1187
|
this.failoverActive.delete(name);
|
|
1139
1188
|
this.cancelIdleButtonRetirement(name);
|
|
@@ -1225,8 +1274,24 @@ export class FleetManager {
|
|
|
1225
1274
|
process.env[key] = value;
|
|
1226
1275
|
}
|
|
1227
1276
|
}
|
|
1277
|
+
/** Initialize auth before any adapter can answer /dashboard. */
|
|
1278
|
+
initializeWebAuthTokens() {
|
|
1279
|
+
this.webToken = loadOrCreateWebToken(this.dataDir);
|
|
1280
|
+
this.viewToken = randomBytes(24).toString("hex");
|
|
1281
|
+
const viewTokenPath = join(this.dataDir, "view.token");
|
|
1282
|
+
writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
|
|
1283
|
+
try {
|
|
1284
|
+
chmodSync(viewTokenPath, 0o600);
|
|
1285
|
+
}
|
|
1286
|
+
catch { /* best effort */ }
|
|
1287
|
+
this.healthServerListening = false;
|
|
1288
|
+
}
|
|
1289
|
+
getDashboardAccess() {
|
|
1290
|
+
return { ready: this.healthServerListening, token: this.webToken };
|
|
1291
|
+
}
|
|
1228
1292
|
/** Start all instances from fleet config */
|
|
1229
1293
|
async startAll(configPath) {
|
|
1294
|
+
const startupStartedAt = Date.now();
|
|
1230
1295
|
FleetManager.signalTarget = this;
|
|
1231
1296
|
this.startupComplete = false;
|
|
1232
1297
|
// Cleared here, not at the end of doStopAll: a stop has an async tail, and
|
|
@@ -1238,6 +1303,7 @@ export class FleetManager {
|
|
|
1238
1303
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
1239
1304
|
const fleet = this.loadConfig(configPath);
|
|
1240
1305
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
1306
|
+
this.initializeWebAuthTokens();
|
|
1241
1307
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
1242
1308
|
// Set tmux socket isolation for custom AGEND_HOME
|
|
1243
1309
|
const { getTmuxSocketName: getSocket } = await import("./paths.js");
|
|
@@ -1493,10 +1559,13 @@ export class FleetManager {
|
|
|
1493
1559
|
const allEntries = Object.entries(fleet.instances);
|
|
1494
1560
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
1495
1561
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
1562
|
+
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
|
|
1496
1563
|
if (generals.length > 0) {
|
|
1497
1564
|
for (const [name, cfg] of generals) {
|
|
1498
1565
|
try {
|
|
1499
1566
|
await this.startInstance(name, cfg, topicMode);
|
|
1567
|
+
if (this.daemons.has(name))
|
|
1568
|
+
startupProgress.markReady();
|
|
1500
1569
|
}
|
|
1501
1570
|
catch (err) {
|
|
1502
1571
|
this.logger.error({ err, name }, "Failed to start general instance");
|
|
@@ -1511,6 +1580,26 @@ export class FleetManager {
|
|
|
1511
1580
|
}
|
|
1512
1581
|
}
|
|
1513
1582
|
}
|
|
1583
|
+
// The adapter must exist before General can receive the progress message.
|
|
1584
|
+
// Start it after General is ready, in parallel with the remaining CLIs, so
|
|
1585
|
+
// progress is visible without adding adapter startup time to the critical path.
|
|
1586
|
+
let adapterStartup = null;
|
|
1587
|
+
let progressStart = Promise.resolve(false);
|
|
1588
|
+
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
1589
|
+
// An adapter becoming reachable during startup can receive messages; make
|
|
1590
|
+
// all existing topic ids routable before opening that inbound path.
|
|
1591
|
+
this.routing.rebuild(fleet);
|
|
1592
|
+
this.reregisterClassicChannels();
|
|
1593
|
+
adapterStartup = (async () => {
|
|
1594
|
+
try {
|
|
1595
|
+
await this.startSharedAdapter(fleet);
|
|
1596
|
+
}
|
|
1597
|
+
catch (err) {
|
|
1598
|
+
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
1599
|
+
}
|
|
1600
|
+
})();
|
|
1601
|
+
progressStart = adapterStartup.then(() => startupProgress.start(this.restartProgressTarget()));
|
|
1602
|
+
}
|
|
1514
1603
|
// The systemd watchdog answers exactly one question: is this process still
|
|
1515
1604
|
// turning its event loop? Pinging from a timer proves that, and after the
|
|
1516
1605
|
// blocking child-process calls were made async it is a meaningful signal —
|
|
@@ -1536,15 +1625,10 @@ export class FleetManager {
|
|
|
1536
1625
|
this.logRotateTimer.unref?.();
|
|
1537
1626
|
// Phase 2: Start remaining instances with staggered concurrency
|
|
1538
1627
|
if (others.length > 0) {
|
|
1539
|
-
await this.startInstancesWithConcurrency(others, topicMode);
|
|
1628
|
+
await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady());
|
|
1540
1629
|
}
|
|
1541
1630
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
1542
|
-
|
|
1543
|
-
await this.startSharedAdapter(fleet);
|
|
1544
|
-
}
|
|
1545
|
-
catch (err) {
|
|
1546
|
-
this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
|
|
1547
|
-
}
|
|
1631
|
+
await adapterStartup;
|
|
1548
1632
|
// Bind every fleet instance deterministically. Explicit channel_id wins;
|
|
1549
1633
|
// otherwise channels[0] is authoritative. Do not infer identity from
|
|
1550
1634
|
// concurrent adapter startup or whichever bot receives a message first.
|
|
@@ -1585,18 +1669,30 @@ export class FleetManager {
|
|
|
1585
1669
|
// Start classic channel instances (parallel, concurrency 3)
|
|
1586
1670
|
if (this.classicChannels) {
|
|
1587
1671
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
1588
|
-
const channels = this.classicChannels.getAll()
|
|
1672
|
+
const channels = this.classicChannels.getAll()
|
|
1673
|
+
.filter(ch => !this.lifecycle.isPaused(ch.instanceName));
|
|
1589
1674
|
const concurrency = 3;
|
|
1590
1675
|
let idx = 0;
|
|
1591
1676
|
while (idx < channels.length) {
|
|
1592
1677
|
const batch = channels.slice(idx, idx + concurrency);
|
|
1593
|
-
await Promise.allSettled(batch.map(
|
|
1678
|
+
await Promise.allSettled(batch.map(async (ch) => {
|
|
1679
|
+
try {
|
|
1680
|
+
await this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
1681
|
+
if (this.daemons.has(ch.instanceName))
|
|
1682
|
+
startupProgress.markReady();
|
|
1683
|
+
}
|
|
1684
|
+
catch (err) {
|
|
1685
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance");
|
|
1686
|
+
}
|
|
1687
|
+
}));
|
|
1594
1688
|
idx += concurrency;
|
|
1595
1689
|
}
|
|
1596
1690
|
}
|
|
1597
1691
|
for (const name of Object.keys(fleet.instances)) {
|
|
1598
1692
|
this.startStatuslineWatcher(name);
|
|
1599
1693
|
}
|
|
1694
|
+
await progressStart;
|
|
1695
|
+
const progressCompleted = await startupProgress.finish();
|
|
1600
1696
|
// Notify General topic that fleet is up
|
|
1601
1697
|
const classicCount = this.classicChannels?.getAll().length ?? 0;
|
|
1602
1698
|
const total = Object.keys(fleet.instances).length + classicCount;
|
|
@@ -1609,7 +1705,7 @@ export class FleetManager {
|
|
|
1609
1705
|
const { createRequire } = await import("node:module");
|
|
1610
1706
|
const _require = createRequire(import.meta.url);
|
|
1611
1707
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
1612
|
-
if (this.adapter && fleet.channel?.group_id) {
|
|
1708
|
+
if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
|
|
1613
1709
|
let text;
|
|
1614
1710
|
if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
1615
1711
|
text = t("fleet.ready", started, total, agendVersion);
|
|
@@ -6331,6 +6427,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6331
6427
|
this.controlClient?.stop();
|
|
6332
6428
|
this.controlClient = null;
|
|
6333
6429
|
if (this.healthServer) {
|
|
6430
|
+
this.healthServerListening = false;
|
|
6334
6431
|
this.healthServer.close();
|
|
6335
6432
|
this.healthServer = null;
|
|
6336
6433
|
}
|
|
@@ -6588,6 +6685,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6588
6685
|
clearTimeout(timeoutHandle);
|
|
6589
6686
|
}
|
|
6590
6687
|
this.logger.info("All instances idle — restarting...");
|
|
6688
|
+
const restartStartedAt = Date.now();
|
|
6689
|
+
// Capture the live adapter/topic before General's daemon is stopped. The
|
|
6690
|
+
// channel adapter remains connected throughout an in-process restart.
|
|
6691
|
+
const progressTarget = this.restartProgressTarget();
|
|
6591
6692
|
this.clearStatuslineWatchers();
|
|
6592
6693
|
for (const [, ipc] of this.instanceIpcClients) {
|
|
6593
6694
|
await ipc.close();
|
|
@@ -6612,15 +6713,25 @@ When users create specialized instances, suggest these configurations:
|
|
|
6612
6713
|
const fleet = this.loadConfig(this.configPath);
|
|
6613
6714
|
this.fleetConfig = fleet;
|
|
6614
6715
|
const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
|
|
6716
|
+
const restartProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), restartStartedAt, this.logger);
|
|
6615
6717
|
// Phase 1: generals first
|
|
6616
6718
|
const restartEntries = Object.entries(fleet.instances);
|
|
6617
6719
|
const restartGenerals = restartEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
6618
6720
|
const restartOthers = restartEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
6619
6721
|
for (const [name, cfg] of restartGenerals) {
|
|
6620
|
-
|
|
6722
|
+
try {
|
|
6723
|
+
await this.startInstance(name, cfg, topicMode);
|
|
6724
|
+
if (this.daemons.has(name))
|
|
6725
|
+
restartProgress.markReady();
|
|
6726
|
+
}
|
|
6727
|
+
catch (err) {
|
|
6728
|
+
this.logger.error({ err, name }, "Failed to start general instance");
|
|
6729
|
+
}
|
|
6621
6730
|
}
|
|
6731
|
+
// General is ready again; now its topic can own the live progress message.
|
|
6732
|
+
await restartProgress.start(progressTarget);
|
|
6622
6733
|
if (restartOthers.length > 0) {
|
|
6623
|
-
await this.startInstancesWithConcurrency(restartOthers, topicMode);
|
|
6734
|
+
await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady());
|
|
6624
6735
|
}
|
|
6625
6736
|
if (topicMode) {
|
|
6626
6737
|
this.routing.rebuild(this.fleetConfig);
|
|
@@ -6629,12 +6740,22 @@ When users create specialized instances, suggest these configurations:
|
|
|
6629
6740
|
// Restart classic channel instances (killed during orphan cleanup)
|
|
6630
6741
|
if (this.classicChannels) {
|
|
6631
6742
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
6632
|
-
const channels = this.classicChannels.getAll()
|
|
6743
|
+
const channels = this.classicChannels.getAll()
|
|
6744
|
+
.filter(ch => !this.lifecycle.isPaused(ch.instanceName));
|
|
6633
6745
|
const concurrency = 3;
|
|
6634
6746
|
let idx = 0;
|
|
6635
6747
|
while (idx < channels.length) {
|
|
6636
6748
|
const batch = channels.slice(idx, idx + concurrency);
|
|
6637
|
-
await Promise.allSettled(batch.map(
|
|
6749
|
+
await Promise.allSettled(batch.map(async (ch) => {
|
|
6750
|
+
try {
|
|
6751
|
+
await this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
6752
|
+
if (this.daemons.has(ch.instanceName))
|
|
6753
|
+
restartProgress.markReady();
|
|
6754
|
+
}
|
|
6755
|
+
catch (err) {
|
|
6756
|
+
this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance");
|
|
6757
|
+
}
|
|
6758
|
+
}));
|
|
6638
6759
|
idx += concurrency;
|
|
6639
6760
|
}
|
|
6640
6761
|
}
|
|
@@ -6643,6 +6764,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6643
6764
|
}
|
|
6644
6765
|
}
|
|
6645
6766
|
this.logger.info("Graceful restart complete");
|
|
6767
|
+
const progressCompleted = await restartProgress.finish();
|
|
6646
6768
|
if (groupId && this.adapter) {
|
|
6647
6769
|
const total = Object.keys(fleet.instances).length;
|
|
6648
6770
|
const started = this.daemons.size;
|
|
@@ -6663,8 +6785,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
6663
6785
|
restartText = t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "))
|
|
6664
6786
|
+ (pausedNames2.length > 0 ? `\n⏸ Paused: ${pausedNames2.join(", ")}` : "");
|
|
6665
6787
|
}
|
|
6666
|
-
|
|
6667
|
-
|
|
6788
|
+
if (!progressCompleted) {
|
|
6789
|
+
await this.adapter.sendText(String(groupId), restartText, notifyOpts)
|
|
6790
|
+
.catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
|
|
6791
|
+
}
|
|
6668
6792
|
// Notify each instance's channel — staggered to avoid rate limit storm
|
|
6669
6793
|
const instances = Object.entries(this.fleetConfig?.instances ?? {});
|
|
6670
6794
|
this.logger.info({ count: instances.length }, "Sending restart notification to instances (staggered)");
|
|
@@ -6799,26 +6923,11 @@ When users create specialized instances, suggest these configurations:
|
|
|
6799
6923
|
// ── Health HTTP endpoint ─────────────────────────────────────────────
|
|
6800
6924
|
startHealthServer(port) {
|
|
6801
6925
|
this.startedAt = Date.now();
|
|
6802
|
-
|
|
6803
|
-
this.
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
try {
|
|
6808
|
-
chmodSync(tokenPath, 0o600);
|
|
6809
|
-
}
|
|
6810
|
-
catch {
|
|
6811
|
-
// best-effort
|
|
6812
|
-
}
|
|
6813
|
-
// Separate read-only token for the /view page: grants terminal-view + profile
|
|
6814
|
-
// read, but never write (POSTs still require the full web token).
|
|
6815
|
-
this.viewToken = randomBytes(24).toString("hex");
|
|
6816
|
-
const viewTokenPath = join(this.dataDir, "view.token");
|
|
6817
|
-
writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
|
|
6818
|
-
try {
|
|
6819
|
-
chmodSync(viewTokenPath, 0o600);
|
|
6820
|
-
}
|
|
6821
|
-
catch { /* best-effort */ }
|
|
6926
|
+
this.healthServerListening = false;
|
|
6927
|
+
this.healthPortRetried = false;
|
|
6928
|
+
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
6929
|
+
if (!this.webToken || !this.viewToken)
|
|
6930
|
+
this.initializeWebAuthTokens();
|
|
6822
6931
|
this.healthServer = createServer((req, res) => {
|
|
6823
6932
|
res.setHeader("Content-Type", "application/json");
|
|
6824
6933
|
// Public health probe — no auth required.
|
|
@@ -6845,7 +6954,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
6845
6954
|
?? (typeof headerToken === "string" ? headerToken : null);
|
|
6846
6955
|
if (!this.webToken || providedToken !== this.webToken) {
|
|
6847
6956
|
res.writeHead(401);
|
|
6848
|
-
res.end(JSON.stringify({ error:
|
|
6957
|
+
res.end(JSON.stringify({ error: WEB_TOKEN_INVALID_MESSAGE }));
|
|
6849
6958
|
return;
|
|
6850
6959
|
}
|
|
6851
6960
|
}
|
|
@@ -7060,10 +7169,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
7060
7169
|
res.writeHead(404);
|
|
7061
7170
|
res.end(JSON.stringify({ error: "not found" }));
|
|
7062
7171
|
});
|
|
7172
|
+
const markListening = (afterTakeover = false) => {
|
|
7173
|
+
this.healthServerListening = true;
|
|
7174
|
+
this.logger.info({ port }, afterTakeover
|
|
7175
|
+
? "Health endpoint listening (after takeover)"
|
|
7176
|
+
: "Health endpoint listening");
|
|
7177
|
+
this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
|
|
7178
|
+
this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
|
|
7179
|
+
};
|
|
7063
7180
|
this.healthServer.on("error", (err) => {
|
|
7181
|
+
this.healthServerListening = false;
|
|
7064
7182
|
if (err.code === "EADDRINUSE") {
|
|
7065
7183
|
if (this.healthPortRetried) {
|
|
7066
|
-
this.logger.
|
|
7184
|
+
this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
|
|
7185
|
+
this.notifyFleetError(`⚠️ Dashboard unavailable — health port ${port} is already in use. Stop the conflicting process or configure a different health_port.`);
|
|
7067
7186
|
return;
|
|
7068
7187
|
}
|
|
7069
7188
|
this.healthPortRetried = true;
|
|
@@ -7084,19 +7203,14 @@ When users create specialized instances, suggest these configurations:
|
|
|
7084
7203
|
setTimeout(() => {
|
|
7085
7204
|
if (!this.healthServer)
|
|
7086
7205
|
return;
|
|
7087
|
-
this.healthServer.listen(port, "127.0.0.1", () =>
|
|
7088
|
-
this.logger.info({ port }, "Health endpoint listening (after takeover)");
|
|
7089
|
-
});
|
|
7206
|
+
this.healthServer.listen(port, "127.0.0.1", () => markListening(true));
|
|
7090
7207
|
}, 1500);
|
|
7091
7208
|
return;
|
|
7092
7209
|
}
|
|
7093
7210
|
this.logger.error({ err, port }, "Health server error");
|
|
7211
|
+
this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
|
|
7094
7212
|
});
|
|
7095
|
-
this.healthServer.listen(port, "127.0.0.1", () =>
|
|
7096
|
-
this.logger.info({ port }, "Health endpoint listening");
|
|
7097
|
-
});
|
|
7098
|
-
this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
|
|
7099
|
-
this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
|
|
7213
|
+
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
7100
7214
|
}
|
|
7101
7215
|
getUiStatus() {
|
|
7102
7216
|
const fleetNames = Object.keys(this.fleetConfig?.instances ?? {});
|