@foxden-app/foxclaw 0.5.40 → 0.5.42
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/CHANGELOG.md +22 -0
- package/dist/auth/cross_node_sync.d.ts +29 -0
- package/dist/auth/cross_node_sync.js +67 -0
- package/dist/controller/controller.js +1 -1
- package/dist/main.js +197 -2
- package/dist/update.d.ts +9 -0
- package/dist/update.js +35 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.42 - 2026-06-18
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 新增跨节点升级广播:一个节点升级成功后会通过现有 auth sync 加密通道广播目标版本,其他节点收到后会在 runtime/auth sync 空闲时自动执行本机 self-update;终端 `foxclaw update` 成功后也会在服务重启时触发广播。
|
|
9
|
+
- 为避免升级风暴,收到广播的节点如果已经是目标版本会跳过;远端请求会记录到 auth sync recent events,并可在 `/auth sync status/events` 中排查。
|
|
10
|
+
- 精简终端 `foxclaw status` 默认输出,集中展示运行状态、Codex/app-server、工作队列、bot 概况、auth sync backlog、最近升级和错误;完整原始 JSON 可通过 `foxclaw status --json` 查看。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- Added cross-node update broadcasting: after one node updates successfully, it broadcasts the target version over the existing encrypted auth sync channel, and peers schedule their own self-update when runtime/auth sync work is idle; terminal `foxclaw update` also broadcasts after the restarted service comes back.
|
|
14
|
+
- To avoid update storms, peers skip broadcasts that target their current version; remote update requests are recorded in auth sync recent events for `/auth sync status/events` diagnostics.
|
|
15
|
+
- Condensed terminal `foxclaw status` by default, focusing on runtime health, Codex/app-server, work queues, bot overview, auth sync backlog, recent update, and errors; full raw JSON remains available via `foxclaw status --json`.
|
|
16
|
+
|
|
17
|
+
## 0.5.41 - 2026-06-18
|
|
18
|
+
|
|
19
|
+
### 中文
|
|
20
|
+
- 将 FoxClaw 的普通 Telegram 回复统一升级为 RichMessage Markdown 优先发送,覆盖短提示、usage、操作结果、升级开始/完成、错误通知和大多数诊断文本。
|
|
21
|
+
- 保持稳定降级:Telegram rich markdown 失败时自动回普通文本;`/status`、`/auth`、`/update` 阻塞诊断等结构化面板继续使用已验证的 RichMessage HTML 表格/列表/details。
|
|
22
|
+
|
|
23
|
+
### English
|
|
24
|
+
- Upgraded FoxClaw's normal Telegram replies to prefer RichMessage Markdown, covering short notices, usage text, action results, update start/completion messages, error notifications, and most diagnostic text.
|
|
25
|
+
- Stable fallback is preserved: Telegram rich markdown failures automatically return to plain text, while structured panels such as `/status`, `/auth`, and blocked `/update` diagnostics continue using the verified RichMessage HTML tables/lists/details path.
|
|
26
|
+
|
|
5
27
|
## 0.5.40 - 2026-06-18
|
|
6
28
|
|
|
7
29
|
### 中文
|
|
@@ -198,6 +198,20 @@ export type AuthSyncNotification = {
|
|
|
198
198
|
peer: string;
|
|
199
199
|
result: AuthSyncPullResponseResult;
|
|
200
200
|
reason: string | null;
|
|
201
|
+
} | {
|
|
202
|
+
kind: 'service_update_sent';
|
|
203
|
+
requestId: string;
|
|
204
|
+
targetVersion: string | null;
|
|
205
|
+
peers: string[];
|
|
206
|
+
} | {
|
|
207
|
+
kind: 'service_update_received';
|
|
208
|
+
requestId: string;
|
|
209
|
+
targetVersion: string | null;
|
|
210
|
+
sourceNodeId: string;
|
|
211
|
+
sourceLabel: string;
|
|
212
|
+
peer: string;
|
|
213
|
+
accepted: boolean;
|
|
214
|
+
reason: string | null;
|
|
201
215
|
} | {
|
|
202
216
|
kind: 'sync_error';
|
|
203
217
|
reason: string;
|
|
@@ -219,6 +233,16 @@ export interface AuthSyncImportCallbacks {
|
|
|
219
233
|
deleted: boolean;
|
|
220
234
|
reason?: string | null;
|
|
221
235
|
}>;
|
|
236
|
+
scheduleServiceUpdate?: (source: {
|
|
237
|
+
requestId: string;
|
|
238
|
+
nodeId: string;
|
|
239
|
+
label?: string | null;
|
|
240
|
+
targetVersion: string | null;
|
|
241
|
+
requestedAt: string;
|
|
242
|
+
}) => Promise<{
|
|
243
|
+
accepted: boolean;
|
|
244
|
+
reason?: string | null;
|
|
245
|
+
}>;
|
|
222
246
|
isIdle: () => boolean;
|
|
223
247
|
notify?: (event: AuthSyncNotification) => Promise<void>;
|
|
224
248
|
}
|
|
@@ -284,8 +308,13 @@ export declare class CrossNodeAuthSync {
|
|
|
284
308
|
acquireRefreshLease(reason: string): Promise<AuthSyncLeaseResult>;
|
|
285
309
|
releaseRefreshLease(leaseId: string | null): Promise<void>;
|
|
286
310
|
testPeers(): Promise<AuthSyncTestResult>;
|
|
311
|
+
publishServiceUpdateRequest(targetVersion: string | null): Promise<{
|
|
312
|
+
sent: number;
|
|
313
|
+
peers: string[];
|
|
314
|
+
}>;
|
|
287
315
|
handleIncomingEnvelope(rawEnvelope: string, peer: AuthSyncPeerIdentity): Promise<boolean>;
|
|
288
316
|
private handleMessage;
|
|
317
|
+
private handleServiceUpdateRequest;
|
|
289
318
|
private handlePullRequest;
|
|
290
319
|
private handlePullResponse;
|
|
291
320
|
private markPullPeerUnavailable;
|
|
@@ -460,6 +460,35 @@ export class CrossNodeAuthSync {
|
|
|
460
460
|
}
|
|
461
461
|
return resultPromise;
|
|
462
462
|
}
|
|
463
|
+
async publishServiceUpdateRequest(targetVersion) {
|
|
464
|
+
if (!this.isReady()) {
|
|
465
|
+
return { sent: 0, peers: [] };
|
|
466
|
+
}
|
|
467
|
+
const requestId = crypto.randomUUID();
|
|
468
|
+
const peers = [...this.peers];
|
|
469
|
+
await this.sendToAll({
|
|
470
|
+
kind: 'service.update.request',
|
|
471
|
+
requestId,
|
|
472
|
+
targetVersion,
|
|
473
|
+
requestedAt: new Date().toISOString(),
|
|
474
|
+
});
|
|
475
|
+
this.recordEvent({
|
|
476
|
+
direction: 'local',
|
|
477
|
+
kind: 'service.update.request',
|
|
478
|
+
stage: 'broadcast',
|
|
479
|
+
peer: null,
|
|
480
|
+
requestId,
|
|
481
|
+
candidateName: null,
|
|
482
|
+
detail: targetVersion ? `target=${targetVersion}` : null,
|
|
483
|
+
});
|
|
484
|
+
this.notify({
|
|
485
|
+
kind: 'service_update_sent',
|
|
486
|
+
requestId,
|
|
487
|
+
targetVersion,
|
|
488
|
+
peers,
|
|
489
|
+
});
|
|
490
|
+
return { sent: peers.length, peers };
|
|
491
|
+
}
|
|
463
492
|
async handleIncomingEnvelope(rawEnvelope, peer) {
|
|
464
493
|
if (!this.isReady() || !this.isAllowedPeer(peer)) {
|
|
465
494
|
return false;
|
|
@@ -531,10 +560,47 @@ export class CrossNodeAuthSync {
|
|
|
531
560
|
this.recordEvent({ direction: 'in', kind: 'lease.release', stage: 'released', peer: normalizePeerIdentity(peer), requestId: message.leaseId, candidateName: null, detail: null });
|
|
532
561
|
}
|
|
533
562
|
return;
|
|
563
|
+
case 'service.update.request':
|
|
564
|
+
await this.handleServiceUpdateRequest(message, senderNodeId, sourceLabel, normalizePeerIdentity(peer));
|
|
565
|
+
return;
|
|
534
566
|
default:
|
|
535
567
|
return;
|
|
536
568
|
}
|
|
537
569
|
}
|
|
570
|
+
async handleServiceUpdateRequest(message, senderNodeId, sourceLabel, peer) {
|
|
571
|
+
let result = {
|
|
572
|
+
accepted: false,
|
|
573
|
+
reason: 'service update scheduling is not configured',
|
|
574
|
+
};
|
|
575
|
+
if (this.callbacks.scheduleServiceUpdate) {
|
|
576
|
+
result = await this.callbacks.scheduleServiceUpdate({
|
|
577
|
+
requestId: message.requestId,
|
|
578
|
+
nodeId: senderNodeId,
|
|
579
|
+
label: sourceLabel,
|
|
580
|
+
targetVersion: message.targetVersion,
|
|
581
|
+
requestedAt: message.requestedAt,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
this.recordEvent({
|
|
585
|
+
direction: 'local',
|
|
586
|
+
kind: 'service.update.request',
|
|
587
|
+
stage: result.accepted ? 'accepted' : 'skipped',
|
|
588
|
+
peer,
|
|
589
|
+
requestId: message.requestId,
|
|
590
|
+
candidateName: null,
|
|
591
|
+
detail: result.reason ?? (message.targetVersion ? `target=${message.targetVersion}` : null),
|
|
592
|
+
});
|
|
593
|
+
this.notify({
|
|
594
|
+
kind: 'service_update_received',
|
|
595
|
+
requestId: message.requestId,
|
|
596
|
+
targetVersion: message.targetVersion,
|
|
597
|
+
sourceNodeId: senderNodeId,
|
|
598
|
+
sourceLabel,
|
|
599
|
+
peer,
|
|
600
|
+
accepted: result.accepted,
|
|
601
|
+
reason: result.reason ?? null,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
538
604
|
async handlePullRequest(message, requesterNodeId, peer) {
|
|
539
605
|
if (!isAuthCandidateName(message.candidateName))
|
|
540
606
|
return;
|
|
@@ -1413,6 +1479,7 @@ function requestIdFromMessage(message) {
|
|
|
1413
1479
|
case 'delete.candidate':
|
|
1414
1480
|
case 'test.ping':
|
|
1415
1481
|
case 'test.pong':
|
|
1482
|
+
case 'service.update.request':
|
|
1416
1483
|
return message.requestId;
|
|
1417
1484
|
case 'lease.request':
|
|
1418
1485
|
return message.leaseId;
|
|
@@ -3361,7 +3361,7 @@ export class BridgeSessionCore {
|
|
|
3361
3361
|
writeRuntimeStatus(this.config.statusPath, status);
|
|
3362
3362
|
}
|
|
3363
3363
|
async sendMessage(scopeId, text, inlineKeyboard) {
|
|
3364
|
-
return this.
|
|
3364
|
+
return this.sendRichMarkdownMessage(scopeId, text, inlineKeyboard);
|
|
3365
3365
|
}
|
|
3366
3366
|
async sendHtmlMessage(scopeId, text, inlineKeyboard) {
|
|
3367
3367
|
return this.messaging.sendHtml(scopeId, text, inlineKeyboard);
|
package/dist/main.js
CHANGED
|
@@ -13,7 +13,7 @@ import { acquireProcessLock, LockHeldError } from './lock.js';
|
|
|
13
13
|
import { readRuntimeStatus, writeRuntimeStatus } from './runtime.js';
|
|
14
14
|
import { buildFoxclawLaunchdPlistText, extractNodePathFromLaunchdPlist, } from './launchd.js';
|
|
15
15
|
import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupContainsSystemdUnit, refreshFoxclawExecStartDropIns, removeFoxclawExecStartDropIns, } from './systemd.js';
|
|
16
|
-
import { createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
|
|
16
|
+
import { clearPendingClusterUpdateBroadcast, createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readPendingClusterUpdateBroadcast, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
|
|
17
17
|
const rawCommand = process.argv[2];
|
|
18
18
|
const command = rawCommand || 'serve';
|
|
19
19
|
loadEnv();
|
|
@@ -31,6 +31,86 @@ const PROXY_ENV_KEYS = [
|
|
|
31
31
|
];
|
|
32
32
|
const STANDARD_NODE_PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
|
|
33
33
|
const LOCAL_AUTH_REFRESH_LEASE_TTL_MS = 10 * 60_000;
|
|
34
|
+
const CLUSTER_UPDATE_BROADCAST_PATH = path.join(APP_HOME, 'runtime', 'pending-cluster-update-broadcast.json');
|
|
35
|
+
function createClusterUpdateScheduler(options) {
|
|
36
|
+
let pending = null;
|
|
37
|
+
let timer = null;
|
|
38
|
+
const scheduleRetry = () => {
|
|
39
|
+
if (timer)
|
|
40
|
+
return;
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
timer = null;
|
|
43
|
+
void tryLaunch().catch((error) => {
|
|
44
|
+
options.logger.warn('cluster.update.launch_failed', { error: serializeError(error) });
|
|
45
|
+
scheduleRetry();
|
|
46
|
+
});
|
|
47
|
+
}, 30_000);
|
|
48
|
+
timer.unref();
|
|
49
|
+
};
|
|
50
|
+
const tryLaunch = async () => {
|
|
51
|
+
if (!pending)
|
|
52
|
+
return;
|
|
53
|
+
const source = pending;
|
|
54
|
+
if (source.targetVersion && source.targetVersion === options.currentVersion()) {
|
|
55
|
+
options.logger.info('cluster.update.skipped_current', { targetVersion: source.targetVersion, requestId: source.requestId });
|
|
56
|
+
pending = null;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!options.canUpdate()) {
|
|
60
|
+
scheduleRetry();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
pending = null;
|
|
64
|
+
options.logger.info('cluster.update.starting', {
|
|
65
|
+
requestId: source.requestId,
|
|
66
|
+
sourceNodeId: source.nodeId,
|
|
67
|
+
sourceLabel: source.label ?? null,
|
|
68
|
+
targetVersion: source.targetVersion,
|
|
69
|
+
});
|
|
70
|
+
await options.selfUpdater.launch(`cluster:${source.nodeId}:${source.requestId}`, 'zh');
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
schedule: async (source) => {
|
|
74
|
+
if (source.targetVersion && source.targetVersion === options.currentVersion()) {
|
|
75
|
+
return { accepted: false, reason: `already at ${source.targetVersion}` };
|
|
76
|
+
}
|
|
77
|
+
if (pending?.requestId === source.requestId) {
|
|
78
|
+
return { accepted: false, reason: 'update request is already scheduled' };
|
|
79
|
+
}
|
|
80
|
+
pending = source;
|
|
81
|
+
if (options.canUpdate()) {
|
|
82
|
+
void tryLaunch().catch((error) => {
|
|
83
|
+
options.logger.warn('cluster.update.launch_failed', { error: serializeError(error) });
|
|
84
|
+
scheduleRetry();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
scheduleRetry();
|
|
89
|
+
}
|
|
90
|
+
return { accepted: true, reason: options.canUpdate() ? 'starting when updater accepts the request' : 'queued until runtime is idle' };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function publishPendingClusterUpdateBroadcast(authSync, logger) {
|
|
95
|
+
if (!authSync)
|
|
96
|
+
return;
|
|
97
|
+
const pending = readPendingClusterUpdateBroadcast(CLUSTER_UPDATE_BROADCAST_PATH);
|
|
98
|
+
if (!pending)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
const result = await authSync.publishServiceUpdateRequest(pending.targetVersion);
|
|
102
|
+
clearPendingClusterUpdateBroadcast(CLUSTER_UPDATE_BROADCAST_PATH);
|
|
103
|
+
logger.info('cluster.update.broadcast_sent', {
|
|
104
|
+
targetVersion: pending.targetVersion,
|
|
105
|
+
fromVersion: pending.fromVersion,
|
|
106
|
+
sent: result.sent,
|
|
107
|
+
peers: result.peers,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
logger.warn('cluster.update.broadcast_failed', { error: serializeError(error) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
34
114
|
function createLocalAuthRefreshLease() {
|
|
35
115
|
let active = null;
|
|
36
116
|
const expire = () => {
|
|
@@ -98,6 +178,7 @@ async function main() {
|
|
|
98
178
|
entryPoint,
|
|
99
179
|
nodePath: process.execPath,
|
|
100
180
|
version: readPackageVersion(),
|
|
181
|
+
clusterBroadcastFile: CLUSTER_UPDATE_BROADCAST_PATH,
|
|
101
182
|
...(process.env.CODEX_CLI_BIN || resolveCommand('codex')
|
|
102
183
|
? { codexCliBin: process.env.CODEX_CLI_BIN || resolveCommand('codex') }
|
|
103
184
|
: {}),
|
|
@@ -129,7 +210,12 @@ async function main() {
|
|
|
129
210
|
console.log('No runtime status found.');
|
|
130
211
|
process.exit(1);
|
|
131
212
|
}
|
|
132
|
-
|
|
213
|
+
if (process.argv.slice(3).includes('--json')) {
|
|
214
|
+
console.log(JSON.stringify(status, null, 2));
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
console.log(formatRuntimeStatusSummary(status));
|
|
218
|
+
}
|
|
133
219
|
return;
|
|
134
220
|
}
|
|
135
221
|
if (command === 'doctor') {
|
|
@@ -183,6 +269,77 @@ Usage:
|
|
|
183
269
|
foxclaw --version
|
|
184
270
|
foxclaw --help`);
|
|
185
271
|
}
|
|
272
|
+
function formatRuntimeStatusSummary(status) {
|
|
273
|
+
const lines = [];
|
|
274
|
+
const age = formatAge(status.updatedAt);
|
|
275
|
+
lines.push(`FoxClaw status: ${status.running ? 'running' : 'stopped'}${status.connected ? ', connected' : ', disconnected'}${age ? ` (${age})` : ''}`);
|
|
276
|
+
if (status.userAgent) {
|
|
277
|
+
lines.push(`Codex: ${status.userAgent}`);
|
|
278
|
+
}
|
|
279
|
+
if (status.codexAppServer) {
|
|
280
|
+
lines.push(`App server: ${status.codexAppServer.running ? 'running' : 'stopped'}${status.codexAppServer.pid ? ` pid=${status.codexAppServer.pid}` : ''}${status.codexAppServer.port ? ` port=${status.codexAppServer.port}` : ''}`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(`Work: active ${status.activeTurns}, queued ${status.queuedTurns}, approvals ${status.pendingApprovals}, questions ${status.pendingUserInputs}`);
|
|
283
|
+
if (status.bots?.length) {
|
|
284
|
+
const activeBots = status.bots
|
|
285
|
+
.filter(bot => bot.activeTurns > 0 || !bot.connected)
|
|
286
|
+
.map(bot => `${bot.username ? `@${bot.username}` : bot.id}:${bot.connected ? `${bot.activeTurns} active` : 'offline'}`);
|
|
287
|
+
lines.push(`Bots: ${status.bots.length}${activeBots.length ? ` (${activeBots.join(', ')})` : ''}`);
|
|
288
|
+
}
|
|
289
|
+
else if (status.botUsername) {
|
|
290
|
+
lines.push(`Bot: @${status.botUsername}`);
|
|
291
|
+
}
|
|
292
|
+
if (status.weixinRuntime) {
|
|
293
|
+
lines.push(`Weixin: ${status.weixinRuntime.connected ? 'connected' : 'disconnected'}, active ${status.weixinRuntime.activeTurns}`);
|
|
294
|
+
}
|
|
295
|
+
if (status.authSync?.enabled) {
|
|
296
|
+
const failures = status.authSync.candidateFailures?.length ?? 0;
|
|
297
|
+
lines.push(`Auth sync: peers ${status.authSync.peers.length}, pending imports ${status.authSync.pendingImports}, failures ${failures}${status.authSync.lastReceivedAt ? `, last received ${formatAge(status.authSync.lastReceivedAt)}` : ''}`);
|
|
298
|
+
if (status.authSync.lastError) {
|
|
299
|
+
lines.push(`Auth sync error: ${status.authSync.lastError}`);
|
|
300
|
+
}
|
|
301
|
+
const latestFailure = status.authSync.candidateFailures?.[0];
|
|
302
|
+
if (latestFailure) {
|
|
303
|
+
lines.push(`Latest auth failure: ${latestFailure.candidateName}: ${truncateStatusLine(latestFailure.reason, 140)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (status.authMirror) {
|
|
307
|
+
lines.push(`Auth mirror: ${status.authMirror.candidateName} from ${status.authMirror.sourceLabel} ${formatAge(status.authMirror.syncedAt)}`);
|
|
308
|
+
}
|
|
309
|
+
if (status.authProactiveRefresh) {
|
|
310
|
+
lines.push(`Auth refresh: ${status.authProactiveRefresh.state}${status.authProactiveRefresh.finishedAt ? ` ${formatAge(status.authProactiveRefresh.finishedAt)}` : ''}`);
|
|
311
|
+
}
|
|
312
|
+
if (status.lastUpdate) {
|
|
313
|
+
lines.push(`Last update: ${status.lastUpdate.fromVersion} -> ${status.lastUpdate.toVersion ?? 'unknown'} ${formatAge(status.lastUpdate.updatedAt)}`);
|
|
314
|
+
}
|
|
315
|
+
if (status.lastError) {
|
|
316
|
+
lines.push(`Last error: ${truncateStatusLine(status.lastError, 160)}`);
|
|
317
|
+
}
|
|
318
|
+
lines.push('Use `foxclaw status --json` for full raw status.');
|
|
319
|
+
return lines.join('\n');
|
|
320
|
+
}
|
|
321
|
+
function formatAge(value) {
|
|
322
|
+
if (!value)
|
|
323
|
+
return '';
|
|
324
|
+
const ms = Date.now() - Date.parse(value);
|
|
325
|
+
if (!Number.isFinite(ms))
|
|
326
|
+
return value;
|
|
327
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
328
|
+
if (seconds < 60)
|
|
329
|
+
return `${seconds}s ago`;
|
|
330
|
+
const minutes = Math.round(seconds / 60);
|
|
331
|
+
if (minutes < 60)
|
|
332
|
+
return `${minutes}m ago`;
|
|
333
|
+
const hours = Math.round(minutes / 60);
|
|
334
|
+
if (hours < 48)
|
|
335
|
+
return `${hours}h ago`;
|
|
336
|
+
const days = Math.round(hours / 24);
|
|
337
|
+
return `${days}d ago`;
|
|
338
|
+
}
|
|
339
|
+
function truncateStatusLine(value, maxLength) {
|
|
340
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
341
|
+
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
342
|
+
}
|
|
186
343
|
async function runServeCli() {
|
|
187
344
|
const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync },] = await Promise.all([
|
|
188
345
|
import('./channels/bridge_messaging_router.js'),
|
|
@@ -330,6 +487,13 @@ async function runServeCli() {
|
|
|
330
487
|
const authSyncLocalIdle = () => runtimes.every((runtime) => runtime.core.isIdleForServiceUpdate())
|
|
331
488
|
&& (!activeWeixinCore || activeWeixinCore.isIdleForServiceUpdate())
|
|
332
489
|
&& mirror.isIdle();
|
|
490
|
+
const clusterUpdateScheduler = createClusterUpdateScheduler({
|
|
491
|
+
selfUpdater,
|
|
492
|
+
canUpdate: () => authSyncLocalIdle()
|
|
493
|
+
&& (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
|
|
494
|
+
currentVersion: readPackageVersion,
|
|
495
|
+
logger,
|
|
496
|
+
});
|
|
333
497
|
const coordinator = {
|
|
334
498
|
canSelfUpdate: () => authSyncLocalIdle()
|
|
335
499
|
&& (authSync ? authSync.isIdle() : localAuthRefreshLease.isIdle()),
|
|
@@ -453,6 +617,7 @@ async function runServeCli() {
|
|
|
453
617
|
}
|
|
454
618
|
return { ok: true, deleted: true, reason: source.reason ?? null };
|
|
455
619
|
},
|
|
620
|
+
scheduleServiceUpdate: (source) => clusterUpdateScheduler.schedule(source),
|
|
456
621
|
isIdle: authSyncLocalIdle,
|
|
457
622
|
notify: createAuthSyncNotifier(store, authSyncTransportBot.bot, authNotificationAggregator, {
|
|
458
623
|
quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
|
|
@@ -463,6 +628,7 @@ async function runServeCli() {
|
|
|
463
628
|
activeAuthSync = authSync;
|
|
464
629
|
attachTelegramAuthSync(authSyncTransportBot.bot, authSync, config, logger);
|
|
465
630
|
authSync.start();
|
|
631
|
+
void publishPendingClusterUpdateBroadcast(authSync, logger);
|
|
466
632
|
}
|
|
467
633
|
mirror.start();
|
|
468
634
|
process.on('unhandledRejection', (error) => {
|
|
@@ -522,6 +688,13 @@ async function runServeCli() {
|
|
|
522
688
|
const singleLocalAuthRefreshLease = createLocalAuthRefreshLease();
|
|
523
689
|
const singleAuthSyncLocalIdle = () => Boolean(core?.isIdleForServiceUpdate())
|
|
524
690
|
&& (!singleMirror || singleMirror.isIdle());
|
|
691
|
+
const singleClusterUpdateScheduler = createClusterUpdateScheduler({
|
|
692
|
+
selfUpdater,
|
|
693
|
+
canUpdate: () => singleAuthSyncLocalIdle()
|
|
694
|
+
&& (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
|
|
695
|
+
currentVersion: readPackageVersion,
|
|
696
|
+
logger,
|
|
697
|
+
});
|
|
525
698
|
const singleCoordinator = config.authSyncEnabled ? {
|
|
526
699
|
canSelfUpdate: () => singleAuthSyncLocalIdle()
|
|
527
700
|
&& (singleAuthSync ? singleAuthSync.isIdle() : singleLocalAuthRefreshLease.isIdle()),
|
|
@@ -614,6 +787,7 @@ async function runServeCli() {
|
|
|
614
787
|
await core.handleExternalCodexAuthCandidateDeleted(candidateName, source.reason ?? null);
|
|
615
788
|
return { ok: true, deleted: true, reason: source.reason ?? null };
|
|
616
789
|
},
|
|
790
|
+
scheduleServiceUpdate: (source) => singleClusterUpdateScheduler.schedule(source),
|
|
617
791
|
isIdle: singleAuthSyncLocalIdle,
|
|
618
792
|
notify: createAuthSyncNotifier(store, bot, authNotificationAggregator, {
|
|
619
793
|
quietAuthPoolMode: () => config.authAutoDeleteNeedsRepair,
|
|
@@ -624,6 +798,7 @@ async function runServeCli() {
|
|
|
624
798
|
activeAuthSync = singleAuthSync;
|
|
625
799
|
attachTelegramAuthSync(bot, singleAuthSync, config, logger);
|
|
626
800
|
singleAuthSync.start();
|
|
801
|
+
void publishPendingClusterUpdateBroadcast(singleAuthSync, logger);
|
|
627
802
|
singleMirror.start();
|
|
628
803
|
}
|
|
629
804
|
const telegram = new TelegramChannelAdapter(core);
|
|
@@ -791,6 +966,8 @@ function isQuietAuthPoolNotification(event) {
|
|
|
791
966
|
case 'recovery_failed':
|
|
792
967
|
case 'pull_request_received':
|
|
793
968
|
case 'pull_response_sent':
|
|
969
|
+
case 'service_update_sent':
|
|
970
|
+
case 'service_update_received':
|
|
794
971
|
return true;
|
|
795
972
|
default:
|
|
796
973
|
return false;
|
|
@@ -871,6 +1048,15 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
871
1048
|
return `收到 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer},请求节点:${event.requesterNodeId}`;
|
|
872
1049
|
case 'pull_response_sent':
|
|
873
1050
|
return `已回应 peer 的 auth 查询:${event.candidateName}\nPeer:${event.peer}\n结果:${formatPullResponseResult(event.result, event.reason, locale)}`;
|
|
1051
|
+
case 'service_update_sent':
|
|
1052
|
+
return `跨节点升级广播已发出:${event.targetVersion ?? 'latest'}\nRequest:${event.requestId}\nPeer:${peers}`;
|
|
1053
|
+
case 'service_update_received':
|
|
1054
|
+
return [
|
|
1055
|
+
`收到跨节点升级请求:${event.targetVersion ?? 'latest'}`,
|
|
1056
|
+
`来源:${formatSource(event.sourceLabel, event.sourceNodeId)},peer ${event.peer}`,
|
|
1057
|
+
`处理:${event.accepted ? '已安排本机空闲后升级' : '已跳过'}`,
|
|
1058
|
+
event.reason ? `原因:${event.reason}` : null,
|
|
1059
|
+
].filter(Boolean).join('\n');
|
|
874
1060
|
case 'sync_error':
|
|
875
1061
|
return `auth sync 需要注意:\n${event.reason}`;
|
|
876
1062
|
}
|
|
@@ -937,6 +1123,15 @@ function formatAuthSyncNotification(locale, event) {
|
|
|
937
1123
|
return `Received peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}, requester node: ${event.requesterNodeId}`;
|
|
938
1124
|
case 'pull_response_sent':
|
|
939
1125
|
return `Replied to peer auth recovery request: ${event.candidateName}\nPeer: ${event.peer}\nResult: ${formatPullResponseResult(event.result, event.reason, locale)}`;
|
|
1126
|
+
case 'service_update_sent':
|
|
1127
|
+
return `Cross-node update broadcast sent: ${event.targetVersion ?? 'latest'}\nRequest: ${event.requestId}\nPeers: ${peers}`;
|
|
1128
|
+
case 'service_update_received':
|
|
1129
|
+
return [
|
|
1130
|
+
`Received cross-node update request: ${event.targetVersion ?? 'latest'}`,
|
|
1131
|
+
`Source: ${formatSource(event.sourceLabel, event.sourceNodeId)}, peer ${event.peer}`,
|
|
1132
|
+
`Action: ${event.accepted ? 'scheduled local update when idle' : 'skipped'}`,
|
|
1133
|
+
event.reason ? `Reason: ${event.reason}` : null,
|
|
1134
|
+
].filter(Boolean).join('\n');
|
|
940
1135
|
case 'sync_error':
|
|
941
1136
|
return `Auth sync needs attention:\n${event.reason}`;
|
|
942
1137
|
}
|
package/dist/update.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ interface PerformSelfUpdateOptions {
|
|
|
42
42
|
nodePath: string;
|
|
43
43
|
version: string;
|
|
44
44
|
notificationFile?: string;
|
|
45
|
+
clusterBroadcastFile?: string;
|
|
45
46
|
codexCliBin?: string;
|
|
46
47
|
env?: NodeJS.ProcessEnv;
|
|
47
48
|
}
|
|
@@ -57,6 +58,11 @@ export interface SelfUpdateOutcome {
|
|
|
57
58
|
toVersion: string | null;
|
|
58
59
|
error: string | null;
|
|
59
60
|
}
|
|
61
|
+
export interface PendingClusterUpdateBroadcast {
|
|
62
|
+
targetVersion: string | null;
|
|
63
|
+
fromVersion: string;
|
|
64
|
+
updatedAt: string;
|
|
65
|
+
}
|
|
60
66
|
export declare function selfUpdateStatusPath(statusPath: string): string;
|
|
61
67
|
export declare function inferPnpmHomeFromEntryPoint(entryPoint: string): string | null;
|
|
62
68
|
export declare function resolveSelfUpdateInstaller(entryPoint: string, nodePath?: string, exists?: (target: string) => boolean, env?: NodeJS.ProcessEnv): SelfUpdateInstaller;
|
|
@@ -76,5 +82,8 @@ export declare function buildSelfUpdateLaunchCommand(options: {
|
|
|
76
82
|
unitName?: string;
|
|
77
83
|
}): SelfUpdateLaunchCommand;
|
|
78
84
|
export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
|
|
85
|
+
export declare function readPendingClusterUpdateBroadcast(filePath: string): PendingClusterUpdateBroadcast | null;
|
|
86
|
+
export declare function writePendingClusterUpdateBroadcast(filePath: string, value: PendingClusterUpdateBroadcast): void;
|
|
87
|
+
export declare function clearPendingClusterUpdateBroadcast(filePath: string): void;
|
|
79
88
|
export declare function extractReleaseNotes(changelog: string, version: string, locale: AppLocale): string[] | null;
|
|
80
89
|
export {};
|
package/dist/update.js
CHANGED
|
@@ -308,6 +308,13 @@ export function performSelfUpdate(options) {
|
|
|
308
308
|
console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
|
|
309
309
|
runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
|
|
310
310
|
completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes);
|
|
311
|
+
if (options.clusterBroadcastFile && env.FOXCLAW_SUPPRESS_UPDATE_BROADCAST !== '1') {
|
|
312
|
+
writePendingClusterUpdateBroadcast(options.clusterBroadcastFile, {
|
|
313
|
+
targetVersion: toVersion,
|
|
314
|
+
fromVersion: options.version,
|
|
315
|
+
updatedAt: new Date().toISOString(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
311
318
|
console.log(`[OK] FoxClaw updated and restarted: ${options.version} -> ${toVersion}`);
|
|
312
319
|
return {
|
|
313
320
|
ok: true,
|
|
@@ -328,6 +335,34 @@ export function performSelfUpdate(options) {
|
|
|
328
335
|
};
|
|
329
336
|
}
|
|
330
337
|
}
|
|
338
|
+
export function readPendingClusterUpdateBroadcast(filePath) {
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
341
|
+
if ((typeof parsed.targetVersion !== 'string' && parsed.targetVersion !== null)
|
|
342
|
+
|| typeof parsed.fromVersion !== 'string'
|
|
343
|
+
|| typeof parsed.updatedAt !== 'string'
|
|
344
|
+
|| !Number.isFinite(Date.parse(parsed.updatedAt))) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
targetVersion: parsed.targetVersion ?? null,
|
|
349
|
+
fromVersion: parsed.fromVersion,
|
|
350
|
+
updatedAt: parsed.updatedAt,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export function writePendingClusterUpdateBroadcast(filePath, value) {
|
|
358
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
359
|
+
const temporaryFile = `${filePath}.${process.pid}.tmp`;
|
|
360
|
+
fs.writeFileSync(temporaryFile, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
361
|
+
fs.renameSync(temporaryFile, filePath);
|
|
362
|
+
}
|
|
363
|
+
export function clearPendingClusterUpdateBroadcast(filePath) {
|
|
364
|
+
fs.rmSync(filePath, { force: true });
|
|
365
|
+
}
|
|
331
366
|
function updateManagedCodexCli(codexCliBin, nodePath, env) {
|
|
332
367
|
const fromVersion = readCodexCliVersion(codexCliBin, env);
|
|
333
368
|
if (!codexCliBin) {
|