@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.1
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 +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +354 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
package/dist/app.js
ADDED
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HostApp` —— 单根对象:把零件接成一台真能跑、能被连的服务(architecture §3 / §4)。
|
|
3
|
+
*
|
|
4
|
+
* 这个文件的边界是三件事:
|
|
5
|
+
*
|
|
6
|
+
* 1. **组装**:按对象图的顺序 `new` 出依赖(库 → 仓储 → 配置 → 运行时 → 服务 → 接线),
|
|
7
|
+
* 每一层都靠构造参数注入;没有模块级单例、没有反射扫描、不读进程工作目录。
|
|
8
|
+
* 2. **启停**:`start()` 决定监听在哪、把**实际**地址写进发现文件;`stop()` 对称地拆回去
|
|
9
|
+
* (摘发现文件、断连接、关上传、回收 Scope、关库)。
|
|
10
|
+
* 3. **判据**:什么时候「什么都不欠了」可以退出(PRD L4 / 裁定 R53),以及启动时
|
|
11
|
+
* 「这台机器上是不是已经有人在服务」(单实例,architecture §2)。
|
|
12
|
+
*
|
|
13
|
+
* 方法实现不在这里:每个域的 handler 在 `handlers/`,由 {@link createHandlers} 拼成一张表交给
|
|
14
|
+
* `WsHub`。本文件只给它们提供持有者(服务、运行时、配置、时钟)与几个只有它才接得起来的钩子:
|
|
15
|
+
* - **配置生效**(`ConfigApplyPort`):改完配置要回收受影响的 Scope(F-E2)并发一条通知(F-F9);
|
|
16
|
+
* - **Scope 装配**(`ScopeRegistry` 的工厂):能力目录由 `DiscoveryService` 按 cwd 算好(R54/L9);
|
|
17
|
+
* - **退出判据**(`checkExit`):连接数、在跑的轮次、Scope 三个来源都收敛到一处。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { mkdir } from "node:fs/promises";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { AgentRepository, ConfigManager } from "@ai-zen/agents-sdk";
|
|
23
|
+
import { readHostSettings, resolveListenTarget, resolvePaths } from "./config.js";
|
|
24
|
+
import { createCapabilities, createHandlers } from "./handlers/index.js";
|
|
25
|
+
import { DEFAULT_LANGUAGE, normalizeLanguage } from "./i18n/index.js";
|
|
26
|
+
import { clearHostFile, createLocalCredential, createTokenVerifier, readHostFile, UploadReceiver, writeHostFile, WsHub, } from "./net/index.js";
|
|
27
|
+
import { ChatRunner, createSdkAgentFactory, createWorkspaceScopeFactory, RunRegistry, ScopeRegistry, systemClock, } from "./runtime/index.js";
|
|
28
|
+
import { ConversationDrafts } from "./runtime/ConversationDrafts.js";
|
|
29
|
+
import { TransientRunner, cwdOfTransientScopeKey, transientAgentFactoryFrom } from "./runtime/TransientRunner.js";
|
|
30
|
+
import { ConversationService, CredentialService, MessageService, SearchService, WorkspaceService, } from "./services/index.js";
|
|
31
|
+
import { AgentService } from "./services/AgentService.js";
|
|
32
|
+
import { AssetService } from "./services/AssetService.js";
|
|
33
|
+
import { AutoNaming } from "./services/AutoNaming.js";
|
|
34
|
+
import { CompletionService } from "./services/CompletionService.js";
|
|
35
|
+
import { ConfigService } from "./services/ConfigService.js";
|
|
36
|
+
import { DiscoveryService } from "./services/DiscoveryService.js";
|
|
37
|
+
import { createSdkHandoffService, HandoffService as HandoffDocumentsService } from "./services/HandoffService.js";
|
|
38
|
+
import { Logger } from "./services/Logger.js";
|
|
39
|
+
import { ModelService } from "./services/ModelService.js";
|
|
40
|
+
import { NoticeService, createEventLocalizer } from "./services/NoticeService.js";
|
|
41
|
+
import { QuestionService, createConversationGuardProbe } from "./services/QuestionService.js";
|
|
42
|
+
import { AssetRepository, ConversationRepository, CredentialRepository, Database, MessageRepository, NoticeRepository, QuestionRepository, WorkspaceRepository, migrate, } from "./storage/index.js";
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// 选项与事实
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** 读不到包元数据时的版本号(服务照常能跑,只是升级判定拿不到真版本) */
|
|
47
|
+
const FALLBACK_VERSION = "0.0.0";
|
|
48
|
+
/** 配置生效那条通知留几秒(F-F9:一次改动合成一条,一秒级的提示归 `info`) */
|
|
49
|
+
const APPLIED_NOTICE_SECONDS = 3;
|
|
50
|
+
/** 版本自检的缺省间隔(D4 / F-E3:低频就够,磁盘上的版本不会一秒一变) */
|
|
51
|
+
const VERSION_CHECK_INTERVAL_MS = 30_000;
|
|
52
|
+
const startRepeatTimer = (task, everyMs) => {
|
|
53
|
+
const timer = setInterval(task, everyMs);
|
|
54
|
+
timer.unref();
|
|
55
|
+
return () => clearInterval(timer);
|
|
56
|
+
};
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// 包版本
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* 宿主版本:读本包的 `package.json`。
|
|
62
|
+
*
|
|
63
|
+
* 相对 `import.meta.url` 找而不是读进程工作目录:宿主是常驻后台进程,工作目录由启动方式
|
|
64
|
+
* 决定(`src/app.ts` 与 `dist/app.js` 的上一级都是包根,两种形态都能读到同一份)。
|
|
65
|
+
*/
|
|
66
|
+
export function resolveHostVersion() {
|
|
67
|
+
return readDiskVersion() ?? FALLBACK_VERSION;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 磁盘上的版本号:读本包的 `package.json`;读不到(或格式不对)返回 `null`。
|
|
71
|
+
*
|
|
72
|
+
* 升级判定(D4 / F-E3)要的就是「磁盘上现在是哪个版本」,所以这里与宿主版本共用
|
|
73
|
+
* 同一处读法,`null` 与 `"0.0.0"` 不混:前者是「不知道」,后者是「真的读到了 0.0.0」。
|
|
74
|
+
*/
|
|
75
|
+
export function readDiskVersion() {
|
|
76
|
+
try {
|
|
77
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
if (isRecord(parsed) && typeof parsed.version === "string" && parsed.version.trim() !== "") {
|
|
80
|
+
return parsed.version.trim();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// 包元数据读不到不影响服务本身:升级判定拿不到真版本而已
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// HostApp
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
export class HostApp {
|
|
92
|
+
// ---- 基础设施 ----
|
|
93
|
+
paths;
|
|
94
|
+
db;
|
|
95
|
+
configManager;
|
|
96
|
+
workspaces;
|
|
97
|
+
conversations;
|
|
98
|
+
messages;
|
|
99
|
+
assets;
|
|
100
|
+
notices;
|
|
101
|
+
questions;
|
|
102
|
+
credentials;
|
|
103
|
+
// ---- 运行时 ----
|
|
104
|
+
scopeRegistry;
|
|
105
|
+
runRegistry;
|
|
106
|
+
/**
|
|
107
|
+
* 服务层要的两件运行时事实:忙不忙、删掉之后的中止。
|
|
108
|
+
*
|
|
109
|
+
* 模型与思考档位不在这里 —— 它们在会话行上。
|
|
110
|
+
*/
|
|
111
|
+
conversationRuntime;
|
|
112
|
+
/** 还没发过第一条消息的对话(R56:`create` 只发 id 与身份,首次 `send` 才落库) */
|
|
113
|
+
conversationDrafts;
|
|
114
|
+
chatRunner;
|
|
115
|
+
transientRunner;
|
|
116
|
+
// ---- 服务 ----
|
|
117
|
+
assetService;
|
|
118
|
+
workspaceService;
|
|
119
|
+
conversationService;
|
|
120
|
+
messageService;
|
|
121
|
+
searchService;
|
|
122
|
+
credentialService;
|
|
123
|
+
discoveryService;
|
|
124
|
+
configService;
|
|
125
|
+
agentService;
|
|
126
|
+
modelService;
|
|
127
|
+
completionService;
|
|
128
|
+
noticeService;
|
|
129
|
+
questionService;
|
|
130
|
+
handoffService;
|
|
131
|
+
autoNaming;
|
|
132
|
+
// ---- 接入 ----
|
|
133
|
+
uploads;
|
|
134
|
+
hub;
|
|
135
|
+
/** 方法名 → 实现(`handlers/createHandlers` 拼出来的那张表) */
|
|
136
|
+
handlers;
|
|
137
|
+
/** 宿主版本(`hello` 与 `host.version` 报的那一个) */
|
|
138
|
+
version;
|
|
139
|
+
/** 能力清单(PRD F-E5:帮助页与指令菜单按它渲染;按给定语言出句子) */
|
|
140
|
+
capabilities;
|
|
141
|
+
/** 进程启动时刻(ISO 8601) */
|
|
142
|
+
startedAt;
|
|
143
|
+
/** 是不是本地实例(PRD A6:决定监听范围与要不要发本地凭证交付文件) */
|
|
144
|
+
local;
|
|
145
|
+
options;
|
|
146
|
+
clock;
|
|
147
|
+
logger;
|
|
148
|
+
/** 自己拥有的文件日志(注入了外部 logger 时为 `null`:那份不归本对象关) */
|
|
149
|
+
fileLogger;
|
|
150
|
+
exitHook;
|
|
151
|
+
/** 磁盘版本的读法(D4 / F-E3 自检用;用例换成假实现) */
|
|
152
|
+
readDiskVersion;
|
|
153
|
+
/** 版本自检的间隔与定时器句柄(`stop()` 里清掉,不留悬挂定时器) */
|
|
154
|
+
versionCheckIntervalMs;
|
|
155
|
+
startVersionTimer;
|
|
156
|
+
/** 取消自检定时器(`null` 表示现在没有在跑) */
|
|
157
|
+
stopVersionTimer = null;
|
|
158
|
+
/** 配置文件给出的端口(第二优先;`undefined` 表示这一档没说话) */
|
|
159
|
+
configPort;
|
|
160
|
+
/** 当前阶段:造好 / 自己监听中 / 挂靠已有实例 / 已收尾 */
|
|
161
|
+
phase = "created";
|
|
162
|
+
listening = null;
|
|
163
|
+
/** 发现文件是不是这一份进程写的(挂靠别人的实例时不能去删它) */
|
|
164
|
+
ownsHostFile = false;
|
|
165
|
+
/** 收尾只做一次(`host.shutdown`、判据、显式 `stop()` 都可能先到) */
|
|
166
|
+
closing = null;
|
|
167
|
+
/** 「真的退出」只交棒一次 */
|
|
168
|
+
exited = false;
|
|
169
|
+
checking = false;
|
|
170
|
+
/** 这条进程至少被用过一次:没有它,刚起来的进程会在第一个客户端连上来之前就判自己该退 */
|
|
171
|
+
served = false;
|
|
172
|
+
stopAllRecycled = null;
|
|
173
|
+
/** 思考策略表快照(装配 agent 时要把档位名展开成参数,而那一层是同步的) */
|
|
174
|
+
presets = null;
|
|
175
|
+
/** 推送入口(造好之前为 `null`:这时没有任何连接可推) */
|
|
176
|
+
hubForPush = null;
|
|
177
|
+
/** 交接服务(SDK 口径):给了就装交接线插件(R59) */
|
|
178
|
+
handoffProvider;
|
|
179
|
+
/** R50 的那条警告只说一次(描述的是装配能力,不是某一轮的错) */
|
|
180
|
+
handoffWarningSent = false;
|
|
181
|
+
constructor(options, paths, db, configManager, configPort, logger) {
|
|
182
|
+
this.options = options;
|
|
183
|
+
this.paths = paths;
|
|
184
|
+
this.db = db;
|
|
185
|
+
this.configManager = configManager;
|
|
186
|
+
this.clock = options.clock ?? systemClock;
|
|
187
|
+
this.logger = logger;
|
|
188
|
+
this.fileLogger = logger instanceof Logger ? logger : null;
|
|
189
|
+
this.exitHook = options.exit ?? defaultExit;
|
|
190
|
+
this.version = options.version ?? resolveHostVersion();
|
|
191
|
+
this.readDiskVersion = options.diskVersion ?? readDiskVersion;
|
|
192
|
+
this.versionCheckIntervalMs = options.versionCheckIntervalMs ?? VERSION_CHECK_INTERVAL_MS;
|
|
193
|
+
this.startVersionTimer = options.startVersionTimer ?? startRepeatTimer;
|
|
194
|
+
this.configPort = configPort;
|
|
195
|
+
this.startedAt = new Date(this.clock.now()).toISOString();
|
|
196
|
+
this.local = options.local ?? true;
|
|
197
|
+
// ---- 1. 仓储(只做 SQL,不含业务) ----
|
|
198
|
+
this.workspaces = new WorkspaceRepository(db);
|
|
199
|
+
this.conversations = new ConversationRepository(db);
|
|
200
|
+
this.messages = new MessageRepository(db);
|
|
201
|
+
this.assets = new AssetRepository(db);
|
|
202
|
+
this.notices = new NoticeRepository(db);
|
|
203
|
+
this.questions = new QuestionRepository(db);
|
|
204
|
+
this.credentials = new CredentialRepository(db);
|
|
205
|
+
/** 助手与子 Agent 的定义仓储(配置层与助手/模型/配置三个服务共用同一份目录) */
|
|
206
|
+
const agentRepository = new AgentRepository(join(configManager.basePath, "agents"));
|
|
207
|
+
const subAgentRepository = new AgentRepository(join(configManager.basePath, "sub-agents"));
|
|
208
|
+
/** 按 workspaceId 取 cwd:发现、补全、配置三个服务都要同一份口径 */
|
|
209
|
+
const findWorkspace = async (workspaceId) => {
|
|
210
|
+
const row = await this.workspaces.get(workspaceId);
|
|
211
|
+
return row ? { cwd: row.cwd } : undefined;
|
|
212
|
+
};
|
|
213
|
+
// ---- 2. 运行时:Scope 的持有、轮次占位、服务层要的窄接口 ----
|
|
214
|
+
// 工厂包一层再交给注册表:它要读发现服务算好的能力目录,而那一层比 Scope 先造好
|
|
215
|
+
const injectedScopeFactory = options.createScope;
|
|
216
|
+
this.scopeRegistry = new ScopeRegistry({
|
|
217
|
+
createScope: injectedScopeFactory ?? ((key) => this.defaultScopeFactory()(key)),
|
|
218
|
+
idleTimeoutMs: options.scopeIdleTimeoutMs,
|
|
219
|
+
clock: this.clock,
|
|
220
|
+
onError: (error, workspaceId) => {
|
|
221
|
+
this.logger.error("回收 Scope 失败", { workspaceId, error: describeError(error) });
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
this.runRegistry = new RunRegistry({ clock: this.clock });
|
|
225
|
+
this.conversationRuntime = this.createConversationRuntime();
|
|
226
|
+
this.conversationDrafts = new ConversationDrafts();
|
|
227
|
+
// 交接服务:入参优先,缺省按 cwd 造一个真的(R59:不再因为「没注入」就不装交接插件)
|
|
228
|
+
this.handoffProvider = options.handoff ?? this.defaultHandoffProvider();
|
|
229
|
+
// ---- 3. 素材(上传落定、厂商 file_id 缓存、「素材 id → 内容段」、`__asset_id` 两端的解析) ----
|
|
230
|
+
this.assetService = new AssetService({
|
|
231
|
+
assets: this.assets,
|
|
232
|
+
// 厂商 file_id 的缓存:这个端点上有就复用,换端点了就内联,都没有才上传
|
|
233
|
+
vendorFiles: this.assets,
|
|
234
|
+
paths,
|
|
235
|
+
resolveModelContext: (modelId) => this.resolveModelContext(modelId),
|
|
236
|
+
onProblem: (problem, context) => {
|
|
237
|
+
this.logger.warn(context, { problem: problem.message, detail: problem.detail });
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
// ---- 4. 服务层(薄服务,运行时通过窄接口注进来) ----
|
|
241
|
+
this.workspaceService = new WorkspaceService({
|
|
242
|
+
workspaces: this.workspaces,
|
|
243
|
+
conversations: this.conversations,
|
|
244
|
+
messages: this.messages,
|
|
245
|
+
assets: this.assets,
|
|
246
|
+
// 删完项目之后:关系行已经随外键没了,归零的素材由素材层删行 + 删文件
|
|
247
|
+
sweepAssets: (assetIds) => this.assetService.sweepUnreferenced(assetIds),
|
|
248
|
+
runtime: this.conversationRuntime,
|
|
249
|
+
now: () => this.clock.now(),
|
|
250
|
+
});
|
|
251
|
+
this.conversationService = new ConversationService({
|
|
252
|
+
conversations: this.conversations,
|
|
253
|
+
messages: this.messages,
|
|
254
|
+
workspaces: this.workspaces,
|
|
255
|
+
assets: this.assets,
|
|
256
|
+
// 撤回 / 删对话之后同一件事:引用归零的素材才删字节
|
|
257
|
+
sweepAssets: (assetIds) => this.assetService.sweepUnreferenced(assetIds),
|
|
258
|
+
questions: this.questions,
|
|
259
|
+
configManager: this.configManager,
|
|
260
|
+
runtime: this.conversationRuntime,
|
|
261
|
+
drafts: this.conversationDrafts,
|
|
262
|
+
// 多端在线同步(L5 / D22):改名/置顶/撤回/删除之后对方窗口得知道去拉
|
|
263
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
264
|
+
now: () => this.clock.now(),
|
|
265
|
+
});
|
|
266
|
+
this.messageService = new MessageService({
|
|
267
|
+
conversations: this.conversations,
|
|
268
|
+
messages: this.messages,
|
|
269
|
+
// 刚建出来、还没发过消息的对话在库里没有行(R56):读消息/现场时按草稿认它
|
|
270
|
+
drafts: this.conversationDrafts,
|
|
271
|
+
});
|
|
272
|
+
this.searchService = new SearchService({
|
|
273
|
+
conversations: this.conversations,
|
|
274
|
+
messages: this.messages,
|
|
275
|
+
summarize: (rows) => this.conversationService.summarize(rows),
|
|
276
|
+
});
|
|
277
|
+
this.credentialService = new CredentialService({
|
|
278
|
+
credentials: this.credentials,
|
|
279
|
+
now: () => this.clock.now(),
|
|
280
|
+
});
|
|
281
|
+
// ---- 5. 配置、发现、模型、助手、补全 ----
|
|
282
|
+
this.discoveryService = new DiscoveryService({
|
|
283
|
+
configManager: this.configManager,
|
|
284
|
+
findWorkspace,
|
|
285
|
+
});
|
|
286
|
+
this.modelService = new ModelService({
|
|
287
|
+
configManager: this.configManager,
|
|
288
|
+
agents: agentRepository,
|
|
289
|
+
});
|
|
290
|
+
this.agentService = new AgentService({
|
|
291
|
+
configManager: this.configManager,
|
|
292
|
+
agents: agentRepository,
|
|
293
|
+
subAgents: subAgentRepository,
|
|
294
|
+
now: () => this.clock.now(),
|
|
295
|
+
});
|
|
296
|
+
this.completionService = new CompletionService({ findWorkspace });
|
|
297
|
+
// ---- 6. 通知、问题、交接、自动命名(它们互相引用,都靠注入) ----
|
|
298
|
+
// agent 工厂只造一次:交接回合与普通对话用同一个(跑完即弃,D28)
|
|
299
|
+
const createAgent = options.createAgent ?? this.defaultAgentFactory();
|
|
300
|
+
this.noticeService = new NoticeService({
|
|
301
|
+
notices: this.notices,
|
|
302
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
303
|
+
now: () => this.clock.now(),
|
|
304
|
+
});
|
|
305
|
+
// PRD F-J2:warn / error 也上通知(永久)。日志口子比通知服务先造出来(早造的服务也要能记),
|
|
306
|
+
// 所以在这里后接一次;回环由 Logger 自己的重入标记挡住。
|
|
307
|
+
this.fileLogger?.setNotifier(async (level, summary) => {
|
|
308
|
+
await this.noticeService.log(level, summary);
|
|
309
|
+
});
|
|
310
|
+
this.handoffService = new HandoffDocumentsService({
|
|
311
|
+
conversations: this.conversations,
|
|
312
|
+
workspaces: this.workspaces,
|
|
313
|
+
messages: this.messages,
|
|
314
|
+
conversationViews: this.conversationService,
|
|
315
|
+
scopes: this.scopeRegistry,
|
|
316
|
+
createAgent,
|
|
317
|
+
runs: this.runRegistry,
|
|
318
|
+
questions: this.questions,
|
|
319
|
+
notices: this.noticeService,
|
|
320
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
321
|
+
now: () => this.clock.now(),
|
|
322
|
+
});
|
|
323
|
+
this.questionService = new QuestionService({
|
|
324
|
+
questions: this.questions,
|
|
325
|
+
runs: this.runRegistry,
|
|
326
|
+
// 硬线跟着模型走(D27),与 `conversation.state` 里那份阈值同一个口径
|
|
327
|
+
guard: createConversationGuardProbe({
|
|
328
|
+
conversations: this.conversations,
|
|
329
|
+
messages: this.messages,
|
|
330
|
+
configManager: this.configManager,
|
|
331
|
+
}),
|
|
332
|
+
handoff: this.handoffService,
|
|
333
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
334
|
+
now: () => this.clock.now(),
|
|
335
|
+
});
|
|
336
|
+
this.autoNaming = new AutoNaming({
|
|
337
|
+
conversations: this.conversations,
|
|
338
|
+
messages: this.messages,
|
|
339
|
+
configManager: this.configManager,
|
|
340
|
+
notices: this.noticeService,
|
|
341
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
342
|
+
now: () => this.clock.now(),
|
|
343
|
+
});
|
|
344
|
+
// 配置改完的「生效」一跳:回收 Scope(重连 MCP)+ 合成一条通知(F-E2 / F-F9)
|
|
345
|
+
this.configService = new ConfigService({
|
|
346
|
+
configManager: this.configManager,
|
|
347
|
+
models: this.modelService,
|
|
348
|
+
discovery: this.discoveryService,
|
|
349
|
+
agents: agentRepository,
|
|
350
|
+
findWorkspace,
|
|
351
|
+
apply: this.createApplyPort(),
|
|
352
|
+
now: () => this.clock.now(),
|
|
353
|
+
});
|
|
354
|
+
// ---- 7. 一轮的执行者(普通对话与临时对话共用同一份 agent 工厂) ----
|
|
355
|
+
this.chatRunner = new ChatRunner({
|
|
356
|
+
messages: this.messages,
|
|
357
|
+
conversations: this.conversations,
|
|
358
|
+
scopes: this.scopeRegistry,
|
|
359
|
+
runs: this.runRegistry,
|
|
360
|
+
createAgent,
|
|
361
|
+
handoff: (context) => this.handoffProvider(context),
|
|
362
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
363
|
+
clock: this.clock,
|
|
364
|
+
prepareAttachments: (context) => this.assetService.prepareAttachments(context),
|
|
365
|
+
// 历史里的 `__asset_id` 每轮发送前按当前模型还原(换模型 / 重启之后历史里的图还能发)
|
|
366
|
+
prepareHistory: (context) => this.assetService.resolveHistory(context),
|
|
367
|
+
// 用户消息落库那一刻扫 content 里的 `__asset_id`,记成关系行(`conversation_assets`)
|
|
368
|
+
linkMessageAssets: (context) => this.assetService.linkMessageAssets(context),
|
|
369
|
+
// 结算时按这段会话行上的模型取交接线(`setModel` 之后就是新模型的阈值)
|
|
370
|
+
defaultLanguage: options.defaultLanguage ?? DEFAULT_LANGUAGE,
|
|
371
|
+
// R59:一轮结算之后宿主自己判断要不要问交接线的问题(D22:在对话停下之后)
|
|
372
|
+
onTurnSettled: (facts) => {
|
|
373
|
+
void this.askHandoffAfterSettle(facts);
|
|
374
|
+
},
|
|
375
|
+
onError: (error, context) => {
|
|
376
|
+
this.logger.error("一轮对话写库失败", { context, error: describeError(error) });
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
// 临时对话:不落库、不推流式事件,一次返回(D3 / R1 / R49);Scope 仍按目录去借
|
|
380
|
+
this.transientRunner = new TransientRunner({
|
|
381
|
+
scopes: this.scopeRegistry,
|
|
382
|
+
createAgent: transientAgentFactoryFrom(createAgent),
|
|
383
|
+
configManager: this.configManager,
|
|
384
|
+
now: () => this.clock.now(),
|
|
385
|
+
});
|
|
386
|
+
// ---- 8. 上传接收(同一条 WS 上的二进制分帧) ----
|
|
387
|
+
this.uploads = new UploadReceiver({
|
|
388
|
+
assetsDir: paths.assetsDir,
|
|
389
|
+
finalize: (request) => this.assetService.finalizeUpload(request),
|
|
390
|
+
// `asset.begin` 带 sha256 时先问一句:同一段对话里同一张图只存一份、只传一次
|
|
391
|
+
findExisting: (input) => this.assetService.findExistingAsset(input),
|
|
392
|
+
// 草稿对话(R56)在第一次带图上传时就落库:素材行按 `conversationId` 挂外键,
|
|
393
|
+
// 所以落库这一步挪到 `asset.begin`(与「第一次 send」同义,`ensurePersisted` 自己幂等)
|
|
394
|
+
ensureConversation: async (conversationId, language) => {
|
|
395
|
+
await this.conversationService.ensurePersisted(conversationId, language);
|
|
396
|
+
},
|
|
397
|
+
onError: (error, context) => {
|
|
398
|
+
this.logger.warn("上传收尾失败", { context, error: describeError(error) });
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
// ---- 9. 接线:方法表 + 一条 WebSocket ----
|
|
402
|
+
this.handlers = createHandlers(this.handlerDeps());
|
|
403
|
+
const target = this.resolveTarget();
|
|
404
|
+
this.capabilities = options.capabilities ?? ((language) => createCapabilities(language));
|
|
405
|
+
this.hub = new WsHub({
|
|
406
|
+
host: target.host,
|
|
407
|
+
port: target.port,
|
|
408
|
+
verifyToken: this.buildVerifier(),
|
|
409
|
+
handlers: this.handlers,
|
|
410
|
+
uploads: this.uploads,
|
|
411
|
+
hostVersion: this.version,
|
|
412
|
+
startedAt: this.startedAt,
|
|
413
|
+
// 握手只用它的 id(R57);`host.capabilities` 按连接语言重渲染(R58)
|
|
414
|
+
capabilities: (language) => this.capabilities(language),
|
|
415
|
+
// 连接没带语言时用配置里的缺省语言(再缺省英文,D8 / R58);配置可变,所以每一跳现读
|
|
416
|
+
defaultLanguage: options.defaultLanguage ?? (() => this.configuredLanguage()),
|
|
417
|
+
// 通知与问题里的句子按收件人那条连接的语言重新渲染一次(D8)
|
|
418
|
+
localizeEvent: createEventLocalizer(),
|
|
419
|
+
// 连接数变化是退出判据的一个触发源(A10:不另起计时器)
|
|
420
|
+
onConnectionsChanged: () => {
|
|
421
|
+
void this.checkExit();
|
|
422
|
+
},
|
|
423
|
+
clock: () => this.clock.now(),
|
|
424
|
+
logger: this.fileLogger?.withSource("ws") ?? this.logger,
|
|
425
|
+
});
|
|
426
|
+
this.hubForPush = this.hub;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* 造一个 `HostApp`(不监听)。
|
|
430
|
+
*
|
|
431
|
+
* 顺序上先读配置文件、再开库:端口写得不对(`config_invalid`)时连库都不必建
|
|
432
|
+
* (裁定 R67:显式给出的非法端口报错,不静默回落)。建库与迁移失败时把库关掉再抛,
|
|
433
|
+
* 避免留下一个开着 worker 的坏实例。
|
|
434
|
+
*/
|
|
435
|
+
static async create(options = {}) {
|
|
436
|
+
const paths = resolvePaths(options.aiZenDir);
|
|
437
|
+
// D25 的中间那一档:只在这一档真的要被用到时才读(参数给了就不必读,坏文件也不该拦住显式端口)
|
|
438
|
+
const configPort = options.configPort ?? (options.port === undefined ? (await readHostSettings(paths)).port : undefined);
|
|
439
|
+
await mkdir(paths.dataRoot, { recursive: true });
|
|
440
|
+
await mkdir(paths.runDir, { recursive: true });
|
|
441
|
+
await mkdir(paths.assetsDir, { recursive: true });
|
|
442
|
+
const db = new Database(paths.dbFile);
|
|
443
|
+
try {
|
|
444
|
+
await migrate(db);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
await db.close();
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
const configManager = new ConfigManager(paths.configFile);
|
|
451
|
+
// SDK 口径的首次初始化:目录 + config.json + 默认助手 + 子 Agent + mcp.json + 出厂 Skill
|
|
452
|
+
// + 思考策略表。已有文件不覆盖(用户自定义的助手定义 `custom: true` 原样保留)。
|
|
453
|
+
await configManager.bootstrap();
|
|
454
|
+
const logging = resolveLogger(options, paths);
|
|
455
|
+
try {
|
|
456
|
+
return new HostApp(options, paths, db, configManager, configPort, logging.logger);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
// 构造途中的抛出(端口不合法、接线造不出来)也要把库关掉:worker 线程不该被漏下
|
|
460
|
+
await db.close();
|
|
461
|
+
await logging.own?.close();
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// -------------------------------------------------------------------------
|
|
466
|
+
// 启停
|
|
467
|
+
// -------------------------------------------------------------------------
|
|
468
|
+
/** 本进程是否在自己监听 */
|
|
469
|
+
get running() {
|
|
470
|
+
return this.phase === "running";
|
|
471
|
+
}
|
|
472
|
+
/** 是不是挂靠在「已经在运行」的那个实例上(本进程没监听) */
|
|
473
|
+
get attached() {
|
|
474
|
+
return this.phase === "attached";
|
|
475
|
+
}
|
|
476
|
+
/** 服务地址(`port: 0` 时是系统分配的那个;没在服务时为 `null`) */
|
|
477
|
+
get address() {
|
|
478
|
+
return this.listening;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* 开始服务。
|
|
482
|
+
*
|
|
483
|
+
* 两步顺序是有意的:
|
|
484
|
+
* 1. **先看这台机器上是不是已经有人在服务**(发现文件是唯一真相)。活着就挂靠它的地址,
|
|
485
|
+
* 本进程不重复监听 —— 同一个数据根起两个进程会各写一份 `host.json`,客户端连谁都不对。
|
|
486
|
+
* 陈旧的(pid 已死、或文件坏了)清掉再起。
|
|
487
|
+
* 2. 自己起:本地实例先备好凭证交付文件 → 监听 → 按**实际**地址写发现文件。客户端读的是
|
|
488
|
+
* 发现文件里的端口,端口被占退回临时端口时启动参数里的那个数已经不是真的了。
|
|
489
|
+
*/
|
|
490
|
+
async start() {
|
|
491
|
+
if (this.phase === "running" || this.phase === "attached") {
|
|
492
|
+
throw new Error("HostApp 已经在服务");
|
|
493
|
+
}
|
|
494
|
+
if (this.phase === "stopped")
|
|
495
|
+
throw new Error("HostApp 已收尾,不能再启动");
|
|
496
|
+
const existing = await this.detectRunningInstance();
|
|
497
|
+
if (existing !== null) {
|
|
498
|
+
this.listening = { host: existing.host, port: existing.port };
|
|
499
|
+
this.phase = "attached";
|
|
500
|
+
this.logger.warn("已有宿主在运行,本进程不重复监听", existing);
|
|
501
|
+
return this.listening;
|
|
502
|
+
}
|
|
503
|
+
// 本地实例的凭证交付:客户端首次连接时直接取这份(PRD A5 / architecture §2)
|
|
504
|
+
if (this.local)
|
|
505
|
+
await createLocalCredential(this.db, this.paths);
|
|
506
|
+
this.presets = await this.configManager.readReasoningPresets();
|
|
507
|
+
const bound = await this.hub.listen();
|
|
508
|
+
const actual = this.hub.address() ?? bound;
|
|
509
|
+
await writeHostFile(this.paths, {
|
|
510
|
+
pid: process.pid,
|
|
511
|
+
host: actual.host,
|
|
512
|
+
port: actual.port,
|
|
513
|
+
version: this.version,
|
|
514
|
+
local: this.local,
|
|
515
|
+
startedAt: this.startedAt,
|
|
516
|
+
});
|
|
517
|
+
this.listening = actual;
|
|
518
|
+
this.ownsHostFile = true;
|
|
519
|
+
this.phase = "running";
|
|
520
|
+
this.stopAllRecycled = this.scopeRegistry.onAllRecycled(() => {
|
|
521
|
+
void this.checkExit();
|
|
522
|
+
});
|
|
523
|
+
this.startVersionCheck();
|
|
524
|
+
this.logInfo("宿主开始监听", {
|
|
525
|
+
host: actual.host,
|
|
526
|
+
port: actual.port,
|
|
527
|
+
version: this.version,
|
|
528
|
+
logFile: this.fileLogger?.filePath ?? null,
|
|
529
|
+
});
|
|
530
|
+
return actual;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* 收尾:对称于 `start()` —— 摘发现文件 → 回收 Scope → 断连接 → 关上传 → 关库。
|
|
534
|
+
*
|
|
535
|
+
* 幂等:`host.shutdown`、判据、信号处理器都可能先到,只有第一次真的做事。
|
|
536
|
+
* 挂靠别人的实例时不摘发现文件(那份不属于本进程)。
|
|
537
|
+
*/
|
|
538
|
+
stop(reason = "requested") {
|
|
539
|
+
this.closing ??= this.doStop(reason);
|
|
540
|
+
return this.closing;
|
|
541
|
+
}
|
|
542
|
+
/** 收尾 + 交棒给「真的退出」(判据成立、`host.shutdown`、信号处理器走这条) */
|
|
543
|
+
async shutdown(reason = "requested") {
|
|
544
|
+
const facts = await this.stop(reason);
|
|
545
|
+
if (!this.exited) {
|
|
546
|
+
this.exited = true;
|
|
547
|
+
await this.exitHook(facts);
|
|
548
|
+
}
|
|
549
|
+
return facts;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* 退出判据(PRD L4 / 裁定 R53):没有连接、没有在跑的轮次、Scope 全部回收 —— 什么都不欠了。
|
|
553
|
+
*
|
|
554
|
+
* 触发源有三个,都不另起计时器(A10:计时器只有一处,即 `ScopeRegistry` 里那份空闲计时):
|
|
555
|
+
* - `ScopeRegistry.onAllRecycled`:Scope 全部拆完那一刻;
|
|
556
|
+
* - 每轮结算(`onTurnSettled`):最后一件欠着的事可能是刚跑完的这一轮;
|
|
557
|
+
* - `WsHub.onConnectionsChanged`:最后一条连接走了。
|
|
558
|
+
*
|
|
559
|
+
* 两个防止误退的口子:
|
|
560
|
+
* 1. **还没被真正用过**时不判:只有握过手(凭证被接受)的连接才算「有人用过它」,
|
|
561
|
+
* 刚起来的进程与端口扫描器留下的空连接都不该把宿主送走;
|
|
562
|
+
* 2. 只在自己监听中判断(造好未启动、挂靠别人、已收尾时都不判)。
|
|
563
|
+
*/
|
|
564
|
+
async checkExit() {
|
|
565
|
+
if (this.phase !== "running" || this.checking)
|
|
566
|
+
return;
|
|
567
|
+
this.checking = true;
|
|
568
|
+
try {
|
|
569
|
+
if (this.hub.connectionInfos().length > 0)
|
|
570
|
+
return;
|
|
571
|
+
if (!this.served)
|
|
572
|
+
return;
|
|
573
|
+
if (this.runRegistry.size() > 0)
|
|
574
|
+
return;
|
|
575
|
+
if (this.scopeRegistry.size() > 0)
|
|
576
|
+
return;
|
|
577
|
+
await this.shutdown("idle");
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
this.checking = false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async doStop(reason) {
|
|
584
|
+
const facts = {
|
|
585
|
+
reason,
|
|
586
|
+
connections: this.hub.connectionInfos().length,
|
|
587
|
+
runs: this.runRegistry.size(),
|
|
588
|
+
scopes: this.scopeRegistry.size(),
|
|
589
|
+
};
|
|
590
|
+
const ownsHostFile = this.ownsHostFile;
|
|
591
|
+
this.phase = "stopped";
|
|
592
|
+
this.ownsHostFile = false;
|
|
593
|
+
this.listening = null;
|
|
594
|
+
this.stopAllRecycled?.();
|
|
595
|
+
this.stopAllRecycled = null;
|
|
596
|
+
this.stopVersionCheck();
|
|
597
|
+
if (ownsHostFile) {
|
|
598
|
+
try {
|
|
599
|
+
await clearHostFile(this.paths);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
this.logger.warn("清理发现文件失败", { error: describeError(error) });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// Scope 一起拆掉:MCP 连接是它持有的,进程退出之后没有别人会替它关(D28 同生共死)
|
|
606
|
+
try {
|
|
607
|
+
await this.scopeRegistry.recycleAll();
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
this.logger.warn("收尾时回收 Scope 失败", { error: describeError(error) });
|
|
611
|
+
}
|
|
612
|
+
await this.hub.close();
|
|
613
|
+
// 关上传:还没结束的分片按 `begin` 记的临时文件清掉,不留垃圾在 assets/ 下
|
|
614
|
+
try {
|
|
615
|
+
await this.uploads.dispose();
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
this.logger.warn("收尾时清理上传失败", { error: describeError(error) });
|
|
619
|
+
}
|
|
620
|
+
await this.db.close();
|
|
621
|
+
// 最后关日志:前面每一步的失败都要能落盘,收尾完再 flush 并关流(PRD F-J2)
|
|
622
|
+
this.logInfo("宿主已收尾", { reason, connections: facts.connections });
|
|
623
|
+
await this.fileLogger?.close();
|
|
624
|
+
return facts;
|
|
625
|
+
}
|
|
626
|
+
// -------------------------------------------------------------------------
|
|
627
|
+
// 升级自检(D4 / F-E3)
|
|
628
|
+
// -------------------------------------------------------------------------
|
|
629
|
+
/**
|
|
630
|
+
* 起一个低频自检:磁盘上的版本号变了(且已经是新的)就收尾退出,由启动方式重拉。
|
|
631
|
+
*
|
|
632
|
+
* 这个定时器不是退出判据的触发源(A10:计时器只有 `ScopeRegistry` 那一份负责回收),
|
|
633
|
+
* 它只把「包已经换了」这个事实变成一次重启。缺省的启动方式带 `unref()`:
|
|
634
|
+
* 只有它还在跑时,也不该拦住进程退出。
|
|
635
|
+
*/
|
|
636
|
+
startVersionCheck() {
|
|
637
|
+
if (this.stopVersionTimer !== null)
|
|
638
|
+
return;
|
|
639
|
+
this.stopVersionTimer = this.startVersionTimer(() => {
|
|
640
|
+
void this.checkDiskVersion();
|
|
641
|
+
}, this.versionCheckIntervalMs);
|
|
642
|
+
}
|
|
643
|
+
/** 清掉自检定时器(`stop()` 里调:用例会断言退出后没有悬挂定时器) */
|
|
644
|
+
stopVersionCheck() {
|
|
645
|
+
if (this.stopVersionTimer === null)
|
|
646
|
+
return;
|
|
647
|
+
this.stopVersionTimer();
|
|
648
|
+
this.stopVersionTimer = null;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* 比一次磁盘版本与运行版本。
|
|
652
|
+
*
|
|
653
|
+
* 三条前提都成立才重启:磁盘读得出来、与正在跑的不是同一个版本、且磁盘上的那个**更新**
|
|
654
|
+
* (回退不算升级,重启只会把用户拉回旧版本)。重启前推一条通知:进程会走,界面上得有个交代。
|
|
655
|
+
*/
|
|
656
|
+
async checkDiskVersion() {
|
|
657
|
+
if (this.phase !== "running")
|
|
658
|
+
return;
|
|
659
|
+
const disk = this.readDiskVersion();
|
|
660
|
+
if (disk === null)
|
|
661
|
+
return;
|
|
662
|
+
if (compareVersions(disk, this.version) <= 0)
|
|
663
|
+
return;
|
|
664
|
+
this.stopVersionCheck();
|
|
665
|
+
this.logInfo("磁盘上的版本已经是新的,安排一次重启", {
|
|
666
|
+
running: this.version,
|
|
667
|
+
disk,
|
|
668
|
+
});
|
|
669
|
+
try {
|
|
670
|
+
await this.noticeService.create({
|
|
671
|
+
level: "info",
|
|
672
|
+
code: "notice.upgradeRestarting",
|
|
673
|
+
params: { version: disk },
|
|
674
|
+
duration: null,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
this.logger.warn("推升级重启的通知失败", { error: describeError(error) });
|
|
679
|
+
}
|
|
680
|
+
await this.shutdown("requested");
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* 启动时的单实例自查(architecture §2:发现文件是「这台机器上谁在服务」的唯一真相)。
|
|
684
|
+
*
|
|
685
|
+
* 三种情况分开处理:
|
|
686
|
+
* - 没有文件:没人服务,正常起;
|
|
687
|
+
* - 文件在、pid 活着:有人服务,返回它的地址(调用方据此挂靠);
|
|
688
|
+
* - 文件在、pid 已死(或文件坏了):留在那儿只会让客户端连一个不存在的进程,清掉再起。
|
|
689
|
+
*/
|
|
690
|
+
async detectRunningInstance() {
|
|
691
|
+
let info;
|
|
692
|
+
try {
|
|
693
|
+
info = await readHostFile(this.paths);
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
// 坏文件与「文件不在」必须分开看:坏的那份拦不住启动,但要清掉,否则每次启动都撞它
|
|
697
|
+
this.logger.warn("发现文件不可用,先清掉再起", { error: describeError(error) });
|
|
698
|
+
await clearHostFile(this.paths).catch(() => undefined);
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
if (info === null)
|
|
702
|
+
return null;
|
|
703
|
+
// 同一个进程里再起一个(宿主把宿主当库用)时,那份记录本来就是我们自己的
|
|
704
|
+
if (info.pid === process.pid || isProcessAlive(info.pid))
|
|
705
|
+
return info;
|
|
706
|
+
try {
|
|
707
|
+
await clearHostFile(this.paths);
|
|
708
|
+
this.logger.warn("清掉陈旧的发现文件(那个进程已经不在了)", { pid: info.pid });
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
this.logger.warn("清理陈旧发现文件失败", { error: describeError(error) });
|
|
712
|
+
}
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
// -------------------------------------------------------------------------
|
|
716
|
+
// handler 的注入点
|
|
717
|
+
// -------------------------------------------------------------------------
|
|
718
|
+
/** 把持有者交给 handler 层:它只拿引用,不 new 任何东西 */
|
|
719
|
+
handlerDeps() {
|
|
720
|
+
return {
|
|
721
|
+
version: this.version,
|
|
722
|
+
startedAt: this.startedAt,
|
|
723
|
+
capabilities: () => this.capabilities(DEFAULT_LANGUAGE),
|
|
724
|
+
workspaces: this.workspaceService,
|
|
725
|
+
conversations: this.conversationService,
|
|
726
|
+
messages: this.messageService,
|
|
727
|
+
search: this.searchService,
|
|
728
|
+
assets: this.assetService,
|
|
729
|
+
config: this.configService,
|
|
730
|
+
agents: this.agentService,
|
|
731
|
+
models: this.modelService,
|
|
732
|
+
completion: this.completionService,
|
|
733
|
+
notices: this.noticeService,
|
|
734
|
+
questions: this.questionService,
|
|
735
|
+
handoff: this.handoffService,
|
|
736
|
+
runner: this.chatRunner,
|
|
737
|
+
scopes: this.scopeRegistry,
|
|
738
|
+
transient: this.transientRunner,
|
|
739
|
+
readConfig: () => this.readConfig(),
|
|
740
|
+
refreshThinkingPresets: async () => {
|
|
741
|
+
this.presets = await this.configManager.readReasoningPresets();
|
|
742
|
+
},
|
|
743
|
+
now: () => this.nowIso(),
|
|
744
|
+
requestStop: () => this.requestStop(),
|
|
745
|
+
onTurnSettled: () => {
|
|
746
|
+
void this.checkExit();
|
|
747
|
+
},
|
|
748
|
+
// 多端在线同步(L5 / D22):删项目带走的那几段对话也要推 `deleted`
|
|
749
|
+
emit: (name, payload) => this.emitEvent(name, payload),
|
|
750
|
+
// D23:有挡着的问题(交接确认、不可撤销动作确认)就不受理这一轮
|
|
751
|
+
assertSendable: async (conversationId) => {
|
|
752
|
+
await this.questionService.assertSendable(conversationId);
|
|
753
|
+
},
|
|
754
|
+
// D2:一轮结算后起一次自动命名(幂等:条件不成立时它自己什么都不做)
|
|
755
|
+
autoNameAfterTurn: async (conversationId, language) => {
|
|
756
|
+
await this.autoNaming.afterTurn(conversationId, language);
|
|
757
|
+
},
|
|
758
|
+
onHookError: (error, hook) => {
|
|
759
|
+
this.logger.warn("结算后的钩子失败", { hook, error: describeError(error) });
|
|
760
|
+
},
|
|
761
|
+
// 现场快照里的问题:`conversation.state` 要能一次给出「现在有没有东西在等回答」(D22 / L5)
|
|
762
|
+
questionsFor: async (conversationId) => (await this.questionService.list({ conversationId })).questions,
|
|
763
|
+
upgrade: (params) => this.upgradeHost(params),
|
|
764
|
+
// 从磁盘重载(F-E1 / R63):走配置服务那条「生效」路(回收 Scope + 合成一条通知);
|
|
765
|
+
// 思考策略表是启动时的快照,重载时一并重读(否则改完档位表要等 setThinking 才生效)
|
|
766
|
+
reload: async (params) => {
|
|
767
|
+
const report = await this.configService.reload(params);
|
|
768
|
+
this.presets = await this.configManager.readReasoningPresets();
|
|
769
|
+
return report;
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* `host.shutdown` 的收尾请求。
|
|
775
|
+
*
|
|
776
|
+
* 收尾放在下一轮事件循环:回包要先把「受理了」写出去,客户端才知道它没白点。
|
|
777
|
+
*/
|
|
778
|
+
requestStop() {
|
|
779
|
+
setTimeout(() => {
|
|
780
|
+
void this.shutdown("requested");
|
|
781
|
+
}, 0);
|
|
782
|
+
}
|
|
783
|
+
// -------------------------------------------------------------------------
|
|
784
|
+
// 工厂(缺省的 SDK 装配)
|
|
785
|
+
// -------------------------------------------------------------------------
|
|
786
|
+
/** 监听地址:`local` 推默认地址,显式 `host` 覆盖它;`port: 0` 原样交给系统分配 */
|
|
787
|
+
resolveTarget() {
|
|
788
|
+
const target = resolveListenTarget({
|
|
789
|
+
port: this.options.port,
|
|
790
|
+
configPort: this.configPort,
|
|
791
|
+
local: this.local,
|
|
792
|
+
});
|
|
793
|
+
return {
|
|
794
|
+
host: this.options.host ?? target.host,
|
|
795
|
+
port: this.options.port === 0 ? 0 : target.port,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
/** 缺省的 Scope 工厂:按 SDK 口径装能力,能力目录由发现服务按 cwd 算好(R54 / L9) */
|
|
799
|
+
defaultScopeFactory() {
|
|
800
|
+
// 一次 acquire 只算一遍路径:四个目录同属「这个 cwd 的能力布局」
|
|
801
|
+
const abilityPaths = (cwd) => this.discoveryService.scopeAbilityPaths(cwd);
|
|
802
|
+
return createWorkspaceScopeFactory({
|
|
803
|
+
// 两个来源:Workspace(按 id 查库)与临时对话(键里带着 cwd,D3「只借 Scope」)
|
|
804
|
+
resolveWorkspace: async (key) => {
|
|
805
|
+
const transientCwd = cwdOfTransientScopeKey(key);
|
|
806
|
+
if (transientCwd !== null)
|
|
807
|
+
return { cwd: transientCwd };
|
|
808
|
+
const row = await this.workspaces.get(key);
|
|
809
|
+
return row ? { cwd: row.cwd } : undefined;
|
|
810
|
+
},
|
|
811
|
+
getConfig: () => this.configManager.read(),
|
|
812
|
+
agentsDir: this.discoveryService.agentsDir,
|
|
813
|
+
skillsPaths: (cwd) => abilityPaths(cwd).skillsPaths,
|
|
814
|
+
toolsPaths: (cwd) => abilityPaths(cwd).toolsPaths,
|
|
815
|
+
subAgentsPaths: (cwd) => abilityPaths(cwd).subAgentsPaths,
|
|
816
|
+
mcpPaths: (cwd) => abilityPaths(cwd).mcpPaths,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/** 缺省的 agent 工厂:SDK 口径装配(硬线、子 Agent 递归、交接线、工具刷新) */
|
|
820
|
+
defaultAgentFactory() {
|
|
821
|
+
const assembled = createSdkAgentFactory({
|
|
822
|
+
handoff: (context) => this.handoffFor(context),
|
|
823
|
+
// 档位名 → 参数补丁要用思考策略表;配置变更后会重读,所以这里给一个现读入口
|
|
824
|
+
thinkingPresets: () => this.presets,
|
|
825
|
+
});
|
|
826
|
+
return assembled;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* 这一轮用哪个交接服务(R59:交接插件真的装上,不再因为「没注入」而整体不装)。
|
|
830
|
+
*
|
|
831
|
+
* 缺省实现按这一轮 Scope 的 cwd 造一个 SDK 交接服务(文档写进项目,D19)。拿不到时
|
|
832
|
+
* 说一声(R50:不静默)——这条警告描述的是装配能力,所以一个进程只说一次。
|
|
833
|
+
*/
|
|
834
|
+
handoffFor(context) {
|
|
835
|
+
const service = this.handoffProvider(context);
|
|
836
|
+
if (service)
|
|
837
|
+
return service;
|
|
838
|
+
if (!this.handoffWarningSent) {
|
|
839
|
+
this.handoffWarningSent = true;
|
|
840
|
+
void this.notifyMissingHandoff();
|
|
841
|
+
}
|
|
842
|
+
return undefined;
|
|
843
|
+
}
|
|
844
|
+
/** 缺省的交接服务:按这一轮的 cwd 造 SDK 口径的那个(文档落 `<cwd>/.ai-zen/handoffs`) */
|
|
845
|
+
defaultHandoffProvider() {
|
|
846
|
+
return (context) => createSdkHandoffService(context.scope.cwd, {
|
|
847
|
+
// 模板来自磁盘上的 `handoff` Skill(F-D6);读不到时 SDK 自己回退到内置模板
|
|
848
|
+
skillDirs: this.discoveryService.scopeAbilityPaths(context.scope.cwd).skillsPaths,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* 一轮结算之后:到了交接线就在对话停下之后问用户(R59 / F-D2 / D22)。
|
|
853
|
+
*
|
|
854
|
+
* 三个前提任缺一条都不问:模型配了交接线且这一轮真的越过了它、对话已经停下(不在流式中途)、
|
|
855
|
+
* 没有已挂着的交接问题(多端看到的是同一行)。问与答之后的事都归 `QuestionService`。
|
|
856
|
+
*/
|
|
857
|
+
async askHandoffAfterSettle(facts) {
|
|
858
|
+
if (facts.handoffTokens <= 0)
|
|
859
|
+
return;
|
|
860
|
+
if (facts.promptTokens < facts.handoffTokens)
|
|
861
|
+
return;
|
|
862
|
+
if (this.runRegistry.isRunning(facts.conversationId))
|
|
863
|
+
return;
|
|
864
|
+
try {
|
|
865
|
+
const pending = await this.questionService.list({
|
|
866
|
+
conversationId: facts.conversationId,
|
|
867
|
+
state: "pending",
|
|
868
|
+
});
|
|
869
|
+
if (pending.questions.some((question) => question.kind === "handoff"))
|
|
870
|
+
return;
|
|
871
|
+
await this.questionService.askHandoff({
|
|
872
|
+
conversationId: facts.conversationId,
|
|
873
|
+
promptTokens: facts.promptTokens,
|
|
874
|
+
handoffTokens: facts.handoffTokens,
|
|
875
|
+
}, facts.language);
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
// 问不出口不影响已经结算的这一轮:记一笔日志,下一轮还有机会
|
|
879
|
+
this.logger.warn("交接线提问失败", {
|
|
880
|
+
conversationId: facts.conversationId,
|
|
881
|
+
error: describeError(error),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** R50 的那一条警告:交接插件没装时至少让人在通知栏里看到 */
|
|
886
|
+
async notifyMissingHandoff() {
|
|
887
|
+
try {
|
|
888
|
+
await this.noticeService.create({
|
|
889
|
+
level: "warning",
|
|
890
|
+
code: "notice.logWarning",
|
|
891
|
+
params: { summary: "handoff plugin not installed (no handoff service injected)" },
|
|
892
|
+
duration: null,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
this.logger.warn("推交接插件缺失的警告失败", { error: describeError(error) });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* 服务层要的那个窄接口(`ConversationRuntimePort`)。
|
|
901
|
+
*
|
|
902
|
+
* `forget` 指向 `ChatRunner.forget`(中止在跑的轮次、清掉这一轮的内存现场)。它只能晚绑定:
|
|
903
|
+
* 服务比执行者先造出来,所以这里闭包住 `this`,调用那一刻才去拿 `chatRunner`。
|
|
904
|
+
*/
|
|
905
|
+
createConversationRuntime() {
|
|
906
|
+
return {
|
|
907
|
+
isRunning: (conversationId) => this.runRegistry.isRunning(conversationId),
|
|
908
|
+
forget: (conversationId) => {
|
|
909
|
+
this.chatRunner.forget(conversationId);
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
/** 素材转换要的实发模型与端点(F-H7 的判定依据:模型能不能看图、端点有没有 Files API) */
|
|
914
|
+
async resolveModelContext(modelId) {
|
|
915
|
+
const config = await this.readConfig();
|
|
916
|
+
const model = modelId === null ? undefined : config.models.find((item) => item.id === modelId);
|
|
917
|
+
const endpoint = model
|
|
918
|
+
? config.endpoints.find((item) => item.id === model.endpointId)
|
|
919
|
+
: undefined;
|
|
920
|
+
return { model: model ?? null, endpoint: endpoint ?? null };
|
|
921
|
+
}
|
|
922
|
+
/** 读配置;坏了就是 `config_invalid`(服务层读配置时也是这个口径) */
|
|
923
|
+
async readConfig() {
|
|
924
|
+
return await this.configManager.read();
|
|
925
|
+
}
|
|
926
|
+
/** 凭证校验:顺手记下「这条进程被用过」(握手成功即算) */
|
|
927
|
+
buildVerifier() {
|
|
928
|
+
const verifier = createTokenVerifier(this.db);
|
|
929
|
+
return {
|
|
930
|
+
verify: async (token) => {
|
|
931
|
+
const verified = await verifier.verify(token);
|
|
932
|
+
if (verified !== null)
|
|
933
|
+
this.served = true;
|
|
934
|
+
return verified;
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
nowIso() {
|
|
939
|
+
return new Date(this.clock.now()).toISOString();
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* 记一条普通信息(生命周期事件这类)。
|
|
943
|
+
*
|
|
944
|
+
* 注入了外部 logger 时它就是 `HubLogger`(只有 warn / error),所以这条走文件日志那一侧;
|
|
945
|
+
* 没有文件日志时什么也不做。
|
|
946
|
+
*/
|
|
947
|
+
logInfo(message, detail) {
|
|
948
|
+
this.fileLogger?.info(message, detail);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* 连接没声明语言时用哪一种(PRD D8 / 裁定 R58)。
|
|
952
|
+
*
|
|
953
|
+
* 真相在 `config.json` 的 `language`(可经 `config.setDefaults` 改),所以每一跳都现读;
|
|
954
|
+
* 配置读不出来或没写这个字段时回落英文 —— 语言这件事不该让握手失败。
|
|
955
|
+
*/
|
|
956
|
+
async configuredLanguage() {
|
|
957
|
+
try {
|
|
958
|
+
const config = await this.configManager.read();
|
|
959
|
+
return normalizeLanguage(config.language);
|
|
960
|
+
}
|
|
961
|
+
catch {
|
|
962
|
+
return DEFAULT_LANGUAGE;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* 推送一个事件(`NoticeService` / `QuestionService` / `ChatRunner` 共用同一个出口)。
|
|
967
|
+
*
|
|
968
|
+
* 造好接线层之前没有连接可推,这时的事件只写库不外发(重连之后客户端自己拉,L5)。
|
|
969
|
+
*/
|
|
970
|
+
emitEvent(name, payload) {
|
|
971
|
+
if (this.hubForPush === null)
|
|
972
|
+
return;
|
|
973
|
+
this.hubForPush.push(name, payload);
|
|
974
|
+
}
|
|
975
|
+
// -------------------------------------------------------------------------
|
|
976
|
+
// 配置生效(F-E2 / F-F9)
|
|
977
|
+
// -------------------------------------------------------------------------
|
|
978
|
+
/**
|
|
979
|
+
* 把「配置变了」这件事办成两件具体的事:回收受影响的能力、合成一条通知。
|
|
980
|
+
*
|
|
981
|
+
* 回收的粒度按改动范围分:只改了某个项目的 MCP(`workspace` 作用域)→ 只拆那个项目的
|
|
982
|
+
* Scope;其余改动(端点 / 模型 / 上限 / 用户级 MCP)影响所有 Scope → 全部回收。
|
|
983
|
+
* `recycleAll` 对正被持有的 Scope 是「打完标记等它释放」,所以报告里的 id 是
|
|
984
|
+
* 「将会重建的那些」—— 正在跑的那一轮不会在写到一半时被抽掉能力(R52)。
|
|
985
|
+
*/
|
|
986
|
+
createApplyPort() {
|
|
987
|
+
return {
|
|
988
|
+
apply: async (change) => {
|
|
989
|
+
const recycledWorkspaceIds = await this.recycleScopesFor(change);
|
|
990
|
+
const notice = await this.noticeService.create({
|
|
991
|
+
level: "info",
|
|
992
|
+
code: "notice.settingsApplied",
|
|
993
|
+
duration: APPLIED_NOTICE_SECONDS,
|
|
994
|
+
});
|
|
995
|
+
return { recycledWorkspaceIds, noticeId: notice.id };
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
async recycleScopesFor(change) {
|
|
1000
|
+
const held = this.scopeRegistry.workspaceIds();
|
|
1001
|
+
if (change.mcp && !change.config && change.workspaceIds.length > 0) {
|
|
1002
|
+
const targets = change.workspaceIds.filter((id) => held.includes(id));
|
|
1003
|
+
for (const workspaceId of targets)
|
|
1004
|
+
await this.scopeRegistry.disposeWorkspace(workspaceId);
|
|
1005
|
+
return targets;
|
|
1006
|
+
}
|
|
1007
|
+
await this.scopeRegistry.recycleAll();
|
|
1008
|
+
return held;
|
|
1009
|
+
}
|
|
1010
|
+
// -------------------------------------------------------------------------
|
|
1011
|
+
// 升级(D4 / F-E3)
|
|
1012
|
+
// -------------------------------------------------------------------------
|
|
1013
|
+
/**
|
|
1014
|
+
* `host.upgrade`:比一次「磁盘上的版本」与「正在跑的版本」。
|
|
1015
|
+
*
|
|
1016
|
+
* 三种结论(契约里的 `HostUpgradeState`):磁盘读不出来 → `not_available`;两个版本一样
|
|
1017
|
+
* 且没有要求别的目标 → `already_latest`;磁盘已经是新的(或请求的目标版本已落盘)→
|
|
1018
|
+
* `restart_scheduled`,随后安排一次收尾:进程退出后由启动方式重新拉起(宿主不自己装包,
|
|
1019
|
+
* D4)。
|
|
1020
|
+
*/
|
|
1021
|
+
async upgradeHost(params) {
|
|
1022
|
+
const diskVersion = this.readDiskVersion();
|
|
1023
|
+
const asked = typeof params.targetVersion === "string" && params.targetVersion.trim() !== ""
|
|
1024
|
+
? params.targetVersion.trim()
|
|
1025
|
+
: null;
|
|
1026
|
+
if (diskVersion === null) {
|
|
1027
|
+
return {
|
|
1028
|
+
state: "not_available",
|
|
1029
|
+
currentVersion: this.version,
|
|
1030
|
+
diskVersion: null,
|
|
1031
|
+
targetVersion: asked,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const target = asked ?? diskVersion;
|
|
1035
|
+
if (diskVersion === this.version && asked === null) {
|
|
1036
|
+
return {
|
|
1037
|
+
state: "already_latest",
|
|
1038
|
+
currentVersion: this.version,
|
|
1039
|
+
diskVersion,
|
|
1040
|
+
targetVersion: target,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
if (target !== diskVersion) {
|
|
1044
|
+
// 要的那个版本还没落到磁盘上:升级本身没完成,这时候重启只会回到旧版本
|
|
1045
|
+
return {
|
|
1046
|
+
state: "not_available",
|
|
1047
|
+
currentVersion: this.version,
|
|
1048
|
+
diskVersion,
|
|
1049
|
+
targetVersion: target,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
if (target === this.version) {
|
|
1053
|
+
return {
|
|
1054
|
+
state: "already_latest",
|
|
1055
|
+
currentVersion: this.version,
|
|
1056
|
+
diskVersion,
|
|
1057
|
+
targetVersion: target,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
setTimeout(() => {
|
|
1061
|
+
void this.shutdown("requested");
|
|
1062
|
+
}, 0);
|
|
1063
|
+
return {
|
|
1064
|
+
state: "restart_scheduled",
|
|
1065
|
+
currentVersion: this.version,
|
|
1066
|
+
diskVersion,
|
|
1067
|
+
targetVersion: target,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
// 单根之外的入口
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
/** 造一个 `HostApp`(`index.ts` 的 `createHost` 就是它) */
|
|
1075
|
+
export function createHost(options = {}) {
|
|
1076
|
+
return HostApp.create(options);
|
|
1077
|
+
}
|
|
1078
|
+
/** 缺省的退出动作:把进程送走(用例注入自己的 `exit` 时不会走到这里) */
|
|
1079
|
+
const defaultExit = () => {
|
|
1080
|
+
process.exit(0);
|
|
1081
|
+
};
|
|
1082
|
+
/**
|
|
1083
|
+
* 日志口子:注入了就用注入的,否则造一个写 `<dataRoot>/logs/host.log` 的文件日志(PRD F-J2)。
|
|
1084
|
+
*
|
|
1085
|
+
* `own` 标的是「这份日志归本对象关」:注入进来的那份归注入方管,收尾时不能替它关。
|
|
1086
|
+
*/
|
|
1087
|
+
function resolveLogger(options, paths) {
|
|
1088
|
+
if (options.logger !== undefined)
|
|
1089
|
+
return { logger: options.logger, own: null };
|
|
1090
|
+
const logger = new Logger({ dir: paths.logsDir });
|
|
1091
|
+
return { logger, own: logger };
|
|
1092
|
+
}
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// 纯函数
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
/**
|
|
1097
|
+
* 那个 pid 还活着吗。
|
|
1098
|
+
*
|
|
1099
|
+
* `process.kill(pid, 0)` 不真的发信号,只做存在性探测:`ESRCH` 表示没有这个进程;
|
|
1100
|
+
* `EPERM` 表示进程在、只是不归我们管(照样算活着)—— 后一种正是「别的用户起的宿主」。
|
|
1101
|
+
*/
|
|
1102
|
+
function isProcessAlive(pid) {
|
|
1103
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
1104
|
+
return false;
|
|
1105
|
+
try {
|
|
1106
|
+
process.kill(pid, 0);
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
catch (error) {
|
|
1110
|
+
return error.code !== "ESRCH";
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
function isRecord(value) {
|
|
1114
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* 比两个版本号(`1.2.0` 与 `1.10.0`):返回 `<0` 表示左边旧、`0` 相同、`>0` 左边新。
|
|
1118
|
+
*
|
|
1119
|
+
* 按 `.` 切段逐段比,两段都是数字时按数字比(`1.10.0` 比 `1.9.0` 新);不是数字的段
|
|
1120
|
+
* (`alpha` 这种预发布标记)按字符串比。够用的理由:升级自检只回答「磁盘上的是不是比我新」,
|
|
1121
|
+
* 不排序、不解析 semver 的完整规则。
|
|
1122
|
+
*/
|
|
1123
|
+
export function compareVersions(left, right) {
|
|
1124
|
+
const partsOf = (version) => version.trim().split(/[.-]/);
|
|
1125
|
+
const leftParts = partsOf(left);
|
|
1126
|
+
const rightParts = partsOf(right);
|
|
1127
|
+
const length = Math.max(leftParts.length, rightParts.length);
|
|
1128
|
+
for (let index = 0; index < length; index += 1) {
|
|
1129
|
+
const a = leftParts[index] ?? "0";
|
|
1130
|
+
const b = rightParts[index] ?? "0";
|
|
1131
|
+
const aNumber = Number(a);
|
|
1132
|
+
const bNumber = Number(b);
|
|
1133
|
+
const bothNumeric = Number.isFinite(aNumber) && Number.isFinite(bNumber) && a !== "" && b !== "";
|
|
1134
|
+
if (bothNumeric) {
|
|
1135
|
+
if (aNumber !== bNumber)
|
|
1136
|
+
return aNumber < bNumber ? -1 : 1;
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
if (a !== b)
|
|
1140
|
+
return a < b ? -1 : 1;
|
|
1141
|
+
}
|
|
1142
|
+
return 0;
|
|
1143
|
+
}
|
|
1144
|
+
function describeError(error) {
|
|
1145
|
+
if (error instanceof Error)
|
|
1146
|
+
return { message: error.message, stack: error.stack };
|
|
1147
|
+
return { message: String(error) };
|
|
1148
|
+
}
|