dsh-hams 0.1.2 → 0.1.4
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/README.md +4 -0
- package/dist/index.js +11335 -0
- package/package.json +9 -5
- package/src/domain/check.ts +0 -193
- package/src/domain/errors.ts +0 -59
- package/src/domain/evidence.ts +0 -134
- package/src/domain/ids.ts +0 -31
- package/src/host/chat-runtime.ts +0 -489
- package/src/host/dsh-adapter.ts +0 -867
- package/src/host/session-adapter.ts +0 -276
- package/src/http/handler.ts +0 -1132
- package/src/index.js +0 -53
- package/src/service/audit.ts +0 -92
- package/src/service/chat.ts +0 -1100
- package/src/service/contracts.ts +0 -193
- package/src/service/defaults.ts +0 -182
- package/src/service/deliverables.ts +0 -240
- package/src/service/hams.ts +0 -3103
- package/src/service/knowledge.ts +0 -940
- package/src/service/member-config.ts +0 -39
- package/src/service/members.ts +0 -506
- package/src/service/memory.ts +0 -375
- package/src/service/permissions.ts +0 -118
- package/src/service/project-lifecycle.ts +0 -233
- package/src/service/projects.ts +0 -840
- package/src/service/receipts.ts +0 -63
- package/src/service/templates.ts +0 -140
- package/src/service/tick.ts +0 -751
- package/src/service/tools.ts +0 -711
- package/src/service/types.ts +0 -219
- package/src/service/work-item-create.ts +0 -159
- package/src/service/workspace-profile.ts +0 -443
- package/src/store/backup.ts +0 -239
- package/src/store/bootstrap.ts +0 -225
- package/src/store/db.ts +0 -179
- package/src/store/lock.ts +0 -194
- package/src/store/migrations.ts +0 -211
- package/src/store/schema/001_p1.sql +0 -846
- package/src/store/schema/001_p1.sql.ts +0 -865
- package/src/store/schema/002_knowledge.sql +0 -54
- package/src/store/schema/002_knowledge.sql.ts +0 -68
- package/src/store/schema/003_projects.sql.ts +0 -3
- package/src/store/schema/004_team.sql.ts +0 -50
- package/src/store/schema/005_target_check.sql.ts +0 -22
- package/src/store/schema/006_target_flow.sql.ts +0 -19
- package/src/store/schema/007_project_delete.sql.ts +0 -17
- package/src/store/schema/008_memory.sql.ts +0 -47
- package/src/store/schema/generate-projects.mjs +0 -16
- package/src/store/seed.ts +0 -114
package/src/host/dsh-adapter.ts
DELETED
|
@@ -1,867 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 宿主适配器:把纯函数 handler 与工具定义接到 Cordis 的 `webServer` / `tools` 上。
|
|
3
|
-
*
|
|
4
|
-
* 只依赖注入的 ctx 形状(`webServer.register` / `tools.register` / 可选 `effect`),
|
|
5
|
-
* 不 import 宿主包——这样既能在测试里用假 ctx 跑,也能在宿主升级时只改这一层。
|
|
6
|
-
*
|
|
7
|
-
* 与 design 的对应:
|
|
8
|
-
* - `webServer.register({kind:'exact', path, handler})`:24 条精确路由(backend §9);
|
|
9
|
-
* - `tools.register(tool)`:11 个窄语义工具,**没有审批/决定/关单/SQL**;
|
|
10
|
-
* - 启动编排沿用存储层:拿锁 → 开库 → 备份 → 迁移 → 种子 → runtime_epoch +1;
|
|
11
|
-
* - 拿不到写锁时降级为只读实例:读路由可用,写命令返回 HOST_CAPABILITY_MISSING 并说明原因。
|
|
12
|
-
*
|
|
13
|
-
* 安全边界(P1 单机):只接受回环 Origin;主体来自配置或 `hams_user(auth_source='local_host')`。
|
|
14
|
-
* 同 OS 账户下的本机管理员仍可直接改库——这条诚实边界写在 README 与状态文档里。
|
|
15
|
-
*/
|
|
16
|
-
import { homedir } from 'node:os'
|
|
17
|
-
import path from 'node:path'
|
|
18
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
19
|
-
import { bootstrapStore, openReadOnlyStore, type BootstrapReport } from '../store/bootstrap.ts'
|
|
20
|
-
import { resolveDataDir, type StoreError } from '../store/db.ts'
|
|
21
|
-
import { HamsService } from '../service/hams.ts'
|
|
22
|
-
import { createHamsHttpHandler, API_PREFIX, ROUTE_PATHS, type AuthResult, type HttpRequest, type HttpResponse } from '../http/handler.ts'
|
|
23
|
-
import { createHamsTools, type ToolDefinition, type ToolExecContext } from '../service/tools.ts'
|
|
24
|
-
import { createTicker, readRuntimeEpoch, recover, type TickerHandle } from '../service/tick.ts'
|
|
25
|
-
import { ChatService } from '../service/chat.ts'
|
|
26
|
-
import { createHostChatRuntime, ensureHamsBotPreset, parseModelChoice, resolveAgentOptions, HAMS_BOT_PRESET } from './chat-runtime.ts'
|
|
27
|
-
import { HamsSessionAdapter } from './session-adapter.ts'
|
|
28
|
-
import { ensureAllDefaultPlans } from '../service/project-lifecycle.ts'
|
|
29
|
-
import { createIdFactory } from '../domain/ids.ts'
|
|
30
|
-
import { HamsError } from '../domain/errors.ts'
|
|
31
|
-
import { ensureStarterTemplate } from '../service/templates.ts'
|
|
32
|
-
import { ensureDefaultBots, type DefaultBotResult } from '../service/defaults.ts'
|
|
33
|
-
import { applyWorkspaceProfile, defaultProfilePath, type WorkspaceProfileReport } from '../service/workspace-profile.ts'
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
export interface HostDisposer {
|
|
37
|
-
(): void
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface HostWebServer {
|
|
41
|
-
register(spec: {
|
|
42
|
-
kind: 'exact'
|
|
43
|
-
path: string
|
|
44
|
-
handler: (req: HostRequest, res: HostResponse) => unknown
|
|
45
|
-
}): HostDisposer
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface HostRequest {
|
|
49
|
-
method?: string
|
|
50
|
-
headers?: Record<string, string | string[] | undefined>
|
|
51
|
-
body?: unknown
|
|
52
|
-
[Symbol.asyncIterator]?: () => AsyncIterator<Buffer | string>
|
|
53
|
-
on?: (event: string, listener: (...args: unknown[]) => void) => unknown
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface HostResponse {
|
|
57
|
-
writeHead?: (status: number, headers?: Record<string, string>) => unknown
|
|
58
|
-
setHeader?: (name: string, value: string) => unknown
|
|
59
|
-
end?: (body?: string | Buffer) => unknown
|
|
60
|
-
statusCode?: number
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface HostTools {
|
|
64
|
-
register(tool: unknown): unknown
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* 宿主 `ctx.connection` 的最小面(Desktop 0.2.2-rc.3 的 `HostConnectionHandle`)。
|
|
69
|
-
*
|
|
70
|
-
* 只声明 C2 真正需要的两个方法,并且都按「传 headers 拿判定」的纯函数用:
|
|
71
|
-
* - `admit()`:官方给「另一个 Web 路由」复用的认证入口,返回已认证 Peer 或 401/403;
|
|
72
|
-
* - `requestRejection()`:更轻的同类入口(只要判定不要 Peer)。
|
|
73
|
-
*
|
|
74
|
-
* 用结构类型而不是 import 宿主包:宿主升级时最多改这一处,测试用假对象即可。
|
|
75
|
-
*/
|
|
76
|
-
export interface HostConnectionPeer {
|
|
77
|
-
readonly principal?: { readonly kind?: string }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export type HostPeerAdmission =
|
|
81
|
-
| { readonly peer: HostConnectionPeer }
|
|
82
|
-
| { readonly rejection: 401 | 403 }
|
|
83
|
-
|
|
84
|
-
export interface HostConnection {
|
|
85
|
-
admit?(request: { readonly headers: Record<string, string | undefined> }): HostPeerAdmission
|
|
86
|
-
requestRejection?(
|
|
87
|
-
request: { readonly headers: Record<string, string | undefined> },
|
|
88
|
-
): 401 | 403 | undefined
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface HostContext {
|
|
92
|
-
webServer: HostWebServer
|
|
93
|
-
tools?: HostTools
|
|
94
|
-
effect?: (fn: () => HostDisposer | void, label?: string) => unknown
|
|
95
|
-
logger?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }
|
|
96
|
-
/** Cordis 的服务读取入口。**必须**用它取 connection:直接读 `ctx.connection`
|
|
97
|
-
* 在 Cordis 里会抛「cannot get property without inject」。 */
|
|
98
|
-
get?: (name: string) => unknown
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* 浏览器会话校验策略:
|
|
103
|
-
* - `auto`:宿主提供 `connection` 就必须过会话校验;没有则退回「只验 Origin」并告警;
|
|
104
|
-
* - `require`:没有 `connection` 就直接拒绝所有界面请求(安全优先的部署用);
|
|
105
|
-
* - `off`:不校验会话(只给测试/排障用,不要装到真实环境)。
|
|
106
|
-
*/
|
|
107
|
-
export type HostSessionMode = 'auto' | 'require' | 'off'
|
|
108
|
-
|
|
109
|
-
export type HostSessionState = 'host' | 'origin-only' | 'off'
|
|
110
|
-
|
|
111
|
-
export interface HamsHostConfig {
|
|
112
|
-
/** 数据目录;空 = $DSH_HOME/hams。 */
|
|
113
|
-
dataDir?: string
|
|
114
|
-
/** tick 周期,默认 5 秒。 */
|
|
115
|
-
tickMs?: number
|
|
116
|
-
/** 本机操作者 member id;空 = 查 hams_user(auth_source='local_host')。 */
|
|
117
|
-
localPrincipalId?: string
|
|
118
|
-
/** 允许的 Origin 前缀;默认只放行回环。 */
|
|
119
|
-
allowedOrigins?: string[]
|
|
120
|
-
/** 首次认领时线程尚未建立,用这个 bot 成员兜底(P1 单 bot);M5 会后由线程取代。 */
|
|
121
|
-
defaultBotMemberId?: string
|
|
122
|
-
/** 关闭工具注册(排障用)。 */
|
|
123
|
-
toolsEnabled?: boolean
|
|
124
|
-
/** 关闭 bot 运行时(不建会话、不派发运行);默认开。 */
|
|
125
|
-
botRuntime?: boolean
|
|
126
|
-
/** 关闭 tick(手工验证用)。 */
|
|
127
|
-
tickEnabled?: boolean
|
|
128
|
-
/** 升级前备份保留份数。 */
|
|
129
|
-
backupKeep?: number
|
|
130
|
-
/** 浏览器会话校验策略,默认 `auto`。 */
|
|
131
|
-
hostSession?: HostSessionMode
|
|
132
|
-
/**
|
|
133
|
-
* 知识屏的真实技能目录;默认 `$DSH_HOME/skills`。
|
|
134
|
-
* 只读每个子目录里的 `SKILL.md`,不写宿主文件。
|
|
135
|
-
*/
|
|
136
|
-
knowledgeSkillDirs?: string[]
|
|
137
|
-
/** 工作区画像文件;默认 `<dataDir>/workspace-profile.json`。 */
|
|
138
|
-
workspaceProfileFile?: string
|
|
139
|
-
/** 画像里相对路径的根;空 = 读画像自己的 workspaceRoot。 */
|
|
140
|
-
workspaceRoot?: string
|
|
141
|
-
/**
|
|
142
|
-
* 显式注入宿主连接服务(装配与测试用)。不传就按 `ctx.get('connection')` 现取,
|
|
143
|
-
* 因为 connection 可能在插件 apply 之后才激活。
|
|
144
|
-
*/
|
|
145
|
-
connection?: HostConnection
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export interface RegisteredHams {
|
|
149
|
-
mode: 'writer' | 'reader'
|
|
150
|
-
service: HamsService
|
|
151
|
-
db: DatabaseSync
|
|
152
|
-
report: BootstrapReport | null
|
|
153
|
-
ticker: TickerHandle | null
|
|
154
|
-
routes: string[]
|
|
155
|
-
toolNames: string[]
|
|
156
|
-
/** 实际生效的浏览器会话校验形态(启动时判定)。 */
|
|
157
|
-
sessionState: HostSessionState
|
|
158
|
-
/** 本次启动给本机负责人岗位补授的能力条数(老库升级时非零)。 */
|
|
159
|
-
operatorCapabilitiesAdded: number
|
|
160
|
-
/** 工作区画像的应用结果(没有画像文件时为 null)。 */
|
|
161
|
-
workspaceProfile: WorkspaceProfileReport | null
|
|
162
|
-
/** 默认 bot(项目管理 / 数据分析)的补齐结果;只读实例为 null。 */
|
|
163
|
-
defaultBots: DefaultBotResult | null
|
|
164
|
-
dispose(): void
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const DEFAULT_ORIGINS = ['http://127.0.0.1:', 'http://localhost:', 'http://[::1]:']
|
|
168
|
-
|
|
169
|
-
function headerValue(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
|
|
170
|
-
const value = headers[name] ?? headers[name.toLowerCase()]
|
|
171
|
-
if (Array.isArray(value)) return value.join(',')
|
|
172
|
-
return value
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function toHeaderRecord(raw: Record<string, string | string[] | undefined> | undefined): Record<string, string | undefined> {
|
|
176
|
-
const out: Record<string, string | undefined> = {}
|
|
177
|
-
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
178
|
-
out[key.toLowerCase()] = Array.isArray(value) ? value.join(',') : value
|
|
179
|
-
}
|
|
180
|
-
return out
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** 读请求体:兼容 Node 流、async iterator 与直接给 string 的假对象。 */
|
|
184
|
-
export async function readRequestBody(request: HostRequest, maxBytes = 256 * 1024): Promise<string> {
|
|
185
|
-
if (typeof request.body === 'string') return request.body
|
|
186
|
-
const chunks: Buffer[] = []
|
|
187
|
-
let size = 0
|
|
188
|
-
const push = (chunk: Buffer | string): void => {
|
|
189
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
|
|
190
|
-
size += buffer.length
|
|
191
|
-
if (size > maxBytes) throw new Error('请求体过大')
|
|
192
|
-
chunks.push(buffer)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (request[Symbol.asyncIterator]) {
|
|
196
|
-
for await (const chunk of request as AsyncIterable<Buffer | string>) push(chunk)
|
|
197
|
-
return Buffer.concat(chunks).toString('utf8')
|
|
198
|
-
}
|
|
199
|
-
if (typeof request.on === 'function') {
|
|
200
|
-
await new Promise<void>((resolve, reject) => {
|
|
201
|
-
request.on!('data', (chunk) => {
|
|
202
|
-
try {
|
|
203
|
-
push(chunk as Buffer | string)
|
|
204
|
-
} catch (error) {
|
|
205
|
-
reject(error)
|
|
206
|
-
}
|
|
207
|
-
})
|
|
208
|
-
request.on!('end', () => resolve())
|
|
209
|
-
request.on!('error', (error) => reject(error))
|
|
210
|
-
})
|
|
211
|
-
return Buffer.concat(chunks).toString('utf8')
|
|
212
|
-
}
|
|
213
|
-
return ''
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function writeResponse(response: HostResponse, result: HttpResponse): void {
|
|
217
|
-
if (typeof response.writeHead === 'function') {
|
|
218
|
-
response.writeHead(result.status, result.headers)
|
|
219
|
-
} else if (typeof response.setHeader === 'function') {
|
|
220
|
-
response.statusCode = result.status
|
|
221
|
-
for (const [key, value] of Object.entries(result.headers)) response.setHeader(key, value)
|
|
222
|
-
} else {
|
|
223
|
-
response.statusCode = result.status
|
|
224
|
-
}
|
|
225
|
-
response.end?.(result.body)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function isAllowedOrigin(origin: string, allowed: string[] = DEFAULT_ORIGINS): boolean {
|
|
229
|
-
return allowed.some((prefix) => origin === prefix.replace(/:$/, '') || origin.startsWith(prefix))
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const UNAUTHENTICATED_MESSAGE = '浏览器会话未通过认证(没有宿主会话 cookie,或已过期)'
|
|
233
|
-
const UNAUTHENTICATED_NEXT_STEP = '用 dsh 打印的带 ?token= 的地址重新打开这一页(或刷新),再操作面板'
|
|
234
|
-
const UNTRUSTED_MESSAGE = '请求来源不被宿主信任(Host/Origin 校验失败)'
|
|
235
|
-
const UNTRUSTED_NEXT_STEP = '只从本机 127.0.0.1/localhost 打开面板;不要用外部页面调用'
|
|
236
|
-
|
|
237
|
-
function unauthenticated(): { ok: false; reason: 'UNAUTHORIZED'; message: string; nextStep: string } {
|
|
238
|
-
return { ok: false, reason: 'UNAUTHORIZED', message: UNAUTHENTICATED_MESSAGE, nextStep: UNAUTHENTICATED_NEXT_STEP }
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function untrusted(): { ok: false; reason: 'FORBIDDEN'; message: string; nextStep: string } {
|
|
242
|
-
return { ok: false, reason: 'FORBIDDEN', message: UNTRUSTED_MESSAGE, nextStep: UNTRUSTED_NEXT_STEP }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export type SessionVerdict =
|
|
246
|
-
| { ok: true }
|
|
247
|
-
| { ok: false; reason: 'UNAUTHORIZED' | 'FORBIDDEN'; message: string; nextStep: string }
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* 用宿主自己的浏览器会话判定「这个请求是不是已认证的本机页面」。
|
|
251
|
-
*
|
|
252
|
-
* 语义对齐宿主(`dsh-client-connection`):认证只认两样东西——进程启动时随 URL
|
|
253
|
-
* 下发、换成熟 cookie 的宿主令牌,以及 `/connect` 换来的 API Peer。HAMS 不自己
|
|
254
|
-
* 造令牌、不登录、不存口令;cookie 是宿主写的(HttpOnly + SameSite=Strict),
|
|
255
|
-
* 我们只读判定结果。
|
|
256
|
-
*
|
|
257
|
-
* 宿主调用失败(抛异常)一律按 403 拒绝——宁可让面板报错,也不要放进来路不明
|
|
258
|
-
* 的写请求。
|
|
259
|
-
*/
|
|
260
|
-
export function checkHostSession(
|
|
261
|
-
connection: HostConnection | undefined,
|
|
262
|
-
headers: Record<string, string | undefined>,
|
|
263
|
-
mode: HostSessionMode = 'auto',
|
|
264
|
-
): SessionVerdict {
|
|
265
|
-
if (mode === 'off') return { ok: true }
|
|
266
|
-
if (!connection) {
|
|
267
|
-
if (mode === 'require') {
|
|
268
|
-
return {
|
|
269
|
-
ok: false,
|
|
270
|
-
reason: 'FORBIDDEN',
|
|
271
|
-
message: '宿主没有提供 connection 服务,无法校验浏览器会话',
|
|
272
|
-
nextStep: '把插件装进带 Web 载体的宿主,或把配置 hostSession 设为 auto(只验 Origin)',
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return { ok: true }
|
|
276
|
-
}
|
|
277
|
-
const request = { headers }
|
|
278
|
-
try {
|
|
279
|
-
if (typeof connection.admit === 'function') {
|
|
280
|
-
const admission = connection.admit(request)
|
|
281
|
-
if ('rejection' in admission) {
|
|
282
|
-
return admission.rejection === 401 ? unauthenticated() : untrusted()
|
|
283
|
-
}
|
|
284
|
-
const kind = admission.peer?.principal?.kind
|
|
285
|
-
// 协议目前只有 owner;将来加了别的 driver(例如 bot peer),界面通道一律不收。
|
|
286
|
-
if (kind !== undefined && kind !== 'owner') {
|
|
287
|
-
return {
|
|
288
|
-
ok: false,
|
|
289
|
-
reason: 'FORBIDDEN',
|
|
290
|
-
message: `主体类型 ${kind} 不能调用 HAMS 界面接口`,
|
|
291
|
-
nextStep: 'bot 只能走工具通道;界面接口只服务已认证的本机操作者',
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return { ok: true }
|
|
295
|
-
}
|
|
296
|
-
if (typeof connection.requestRejection === 'function') {
|
|
297
|
-
const rejection = connection.requestRejection(request)
|
|
298
|
-
if (rejection === 401) return unauthenticated()
|
|
299
|
-
if (rejection === 403) return untrusted()
|
|
300
|
-
return { ok: true }
|
|
301
|
-
}
|
|
302
|
-
if (mode === 'require') {
|
|
303
|
-
return {
|
|
304
|
-
ok: false,
|
|
305
|
-
reason: 'FORBIDDEN',
|
|
306
|
-
message: '宿主 connection 服务没有可用的认证入口',
|
|
307
|
-
nextStep: '确认宿主版本提供 admit/requestRejection,或把 hostSession 设为 auto',
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return { ok: true }
|
|
311
|
-
} catch (error) {
|
|
312
|
-
return {
|
|
313
|
-
ok: false,
|
|
314
|
-
reason: 'FORBIDDEN',
|
|
315
|
-
message: `宿主会话校验失败:${String((error as Error)?.message ?? error)}`,
|
|
316
|
-
nextStep: '看宿主日志确认 connection 服务状态后重试',
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* P1 鉴权:Origin 白名单 → 宿主浏览器会话 → 主体。
|
|
323
|
-
*
|
|
324
|
-
* 主体仍然是「本机负责人」(单机模式没有登录页),但**能不能进来**由宿主会话
|
|
325
|
-
* 说了算:没有换过宿主令牌的请求拿不到 cookie,也就到不了业务层。
|
|
326
|
-
*/
|
|
327
|
-
export function createLocalAuthenticator(
|
|
328
|
-
db: DatabaseSync,
|
|
329
|
-
config: HamsHostConfig,
|
|
330
|
-
resolveConnection?: () => HostConnection | undefined,
|
|
331
|
-
): (request: HttpRequest) => AuthResult {
|
|
332
|
-
const allowed = config.allowedOrigins ?? DEFAULT_ORIGINS
|
|
333
|
-
const mode = config.hostSession ?? 'auto'
|
|
334
|
-
return (request) => {
|
|
335
|
-
const origin = request.headers.origin
|
|
336
|
-
if (origin && !isAllowedOrigin(origin, allowed)) {
|
|
337
|
-
return { ok: false, reason: 'FORBIDDEN', message: `非同源请求:${origin}`, nextStep: UNTRUSTED_NEXT_STEP }
|
|
338
|
-
}
|
|
339
|
-
const verdict = checkHostSession(resolveConnection?.(), request.headers, mode)
|
|
340
|
-
if (!verdict.ok) return verdict
|
|
341
|
-
|
|
342
|
-
let principalId = config.localPrincipalId ?? ''
|
|
343
|
-
if (!principalId) {
|
|
344
|
-
const row = db
|
|
345
|
-
.prepare("SELECT member_id FROM hams_user WHERE auth_source = 'local_host' AND status = 'active' LIMIT 1")
|
|
346
|
-
.get() as { member_id: string } | undefined
|
|
347
|
-
principalId = row?.member_id ?? ''
|
|
348
|
-
}
|
|
349
|
-
if (!principalId) {
|
|
350
|
-
return {
|
|
351
|
-
ok: false,
|
|
352
|
-
reason: 'FORBIDDEN',
|
|
353
|
-
message: '还没有绑定本机操作者:请先建立 hams_user(auth_source=local_host) 或配置 localPrincipalId',
|
|
354
|
-
nextStep: '重启插件会首次自动创建本机操作者;若仍失败,检查 hams_user 表',
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return { ok: true, principalId, authSubject: origin ?? 'local_host' }
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* 参数直接保持标准 JSON Schema(`required` 是对象层的数组)。
|
|
363
|
-
*
|
|
364
|
-
* 实测(2026-11-14,Desktop 0.2.2-rc.3):`ctx.tools.register` 校验的是标准 JSON Schema,
|
|
365
|
-
* 属性节点上标 `required: true` 会被拒:JsonSchemaError: ... is not supported on type "boolean"。
|
|
366
|
-
*/
|
|
367
|
-
export function toHostParameters(schema: Record<string, unknown>): Record<string, unknown> {
|
|
368
|
-
const properties = (schema.properties ?? {}) as Record<string, unknown>
|
|
369
|
-
return {
|
|
370
|
-
type: 'object',
|
|
371
|
-
properties,
|
|
372
|
-
...(Array.isArray(schema.required) && schema.required.length ? { required: schema.required } : {}),
|
|
373
|
-
additionalProperties: schema.additionalProperties === undefined ? false : schema.additionalProperties,
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
export function hostToolOutput(renderKey = 'render'): Record<string, unknown> {
|
|
378
|
-
return {
|
|
379
|
-
schema: {
|
|
380
|
-
type: 'object',
|
|
381
|
-
additionalProperties: true,
|
|
382
|
-
properties: {
|
|
383
|
-
ok: { type: 'boolean' },
|
|
384
|
-
[renderKey]: { type: 'string' },
|
|
385
|
-
},
|
|
386
|
-
required: ['ok', renderKey],
|
|
387
|
-
},
|
|
388
|
-
render: (_args: unknown, value: Record<string, unknown>) => [
|
|
389
|
-
{ type: 'text', text: String(value?.[renderKey] ?? JSON.stringify(value)) },
|
|
390
|
-
],
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/** 把工具结果压成「模型能读的一段文字 + 机器可读的 ok」。 */
|
|
395
|
-
export function renderToolResult(name: string, result: { ok: boolean; data?: unknown; error?: { code: string; message: string; nextStep: string } }): {
|
|
396
|
-
ok: boolean
|
|
397
|
-
render: string
|
|
398
|
-
code?: string
|
|
399
|
-
} {
|
|
400
|
-
if (result.ok) {
|
|
401
|
-
return {
|
|
402
|
-
ok: true,
|
|
403
|
-
render: `${name} 成功:${JSON.stringify(result.data ?? {}, null, 2).slice(0, 4000)}`,
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return {
|
|
407
|
-
ok: false,
|
|
408
|
-
code: result.error?.code,
|
|
409
|
-
render: `${name} 失败(${result.error?.code}):${result.error?.message}\n下一步:${result.error?.nextStep}`,
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export interface BuildHostToolsOptions {
|
|
414
|
-
defineTool?: (definition: Record<string, unknown>) => unknown
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export function toHostTools(
|
|
418
|
-
definitions: ToolDefinition[],
|
|
419
|
-
options: BuildHostToolsOptions = {},
|
|
420
|
-
): unknown[] {
|
|
421
|
-
const define = options.defineTool ?? ((definition: Record<string, unknown>) => definition)
|
|
422
|
-
return definitions.map((definition) => {
|
|
423
|
-
const parameters = toHostParameters(definition.parameters)
|
|
424
|
-
const tool = define({
|
|
425
|
-
name: definition.name,
|
|
426
|
-
description: definition.description,
|
|
427
|
-
parameters,
|
|
428
|
-
output: hostToolOutput(),
|
|
429
|
-
async execute(args: unknown, exec: { agent?: { session?: { id?: string } }; sessionId?: string }): Promise<unknown> {
|
|
430
|
-
const sessionId = exec?.agent?.session?.id ?? exec?.sessionId ?? ''
|
|
431
|
-
const result = await definition.execute(args, { sessionId } as ToolExecContext)
|
|
432
|
-
return renderToolResult(definition.name, result)
|
|
433
|
-
},
|
|
434
|
-
})
|
|
435
|
-
return tool
|
|
436
|
-
})
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* P1 首次使用:宿主本机操作者还没有档案时,建一个最小可用的人 + 岗位 + 登录身份。
|
|
441
|
-
*
|
|
442
|
-
* 这是设计里「宿主本机令牌映射本机负责人」的落地:单机模式没有登录页,第一次打开面板
|
|
443
|
-
* 就得有一个能审批、能建单的人。只建一次;已有 hams_user(local_host) 就什么都不做。
|
|
444
|
-
* 不写任何业务数据(项目、工作项仍要人自己建)。
|
|
445
|
-
*/
|
|
446
|
-
export function ensureLocalOperator(db: DatabaseSync, now = Date.now()): string {
|
|
447
|
-
const existing = db
|
|
448
|
-
.prepare("SELECT member_id FROM hams_user WHERE auth_source = 'local_host' AND status = 'active' LIMIT 1")
|
|
449
|
-
.get() as { member_id: string } | undefined
|
|
450
|
-
if (existing) return existing.member_id
|
|
451
|
-
|
|
452
|
-
const memberId = 'h-owner'
|
|
453
|
-
const positionId = 'pos-owner'
|
|
454
|
-
db.exec('BEGIN IMMEDIATE')
|
|
455
|
-
try {
|
|
456
|
-
const member = db.prepare('SELECT id FROM member WHERE id = ?').get(memberId) as { id: string } | undefined
|
|
457
|
-
if (!member) {
|
|
458
|
-
db.prepare(
|
|
459
|
-
`INSERT INTO member (id, kind, name, role, current_version, created_at, updated_at)
|
|
460
|
-
VALUES (?, 'human', '本机操作者', 'coordinator', 0, ?, ?)`,
|
|
461
|
-
).run(memberId, now, now)
|
|
462
|
-
}
|
|
463
|
-
const position = db.prepare('SELECT id FROM position WHERE id = ?').get(positionId) as { id: string } | undefined
|
|
464
|
-
if (!position) {
|
|
465
|
-
db.prepare("INSERT INTO position (id, name, domain, description) VALUES (?, '本机负责人', NULL, 'P1 自动创建')").run(positionId)
|
|
466
|
-
}
|
|
467
|
-
const capability = db.prepare(
|
|
468
|
-
'INSERT OR IGNORE INTO position_permission (position_id, capability, scope) VALUES (?, ?, ?)',
|
|
469
|
-
)
|
|
470
|
-
for (const name of [
|
|
471
|
-
'work_item.create',
|
|
472
|
-
'work_item.accept',
|
|
473
|
-
'work_item.manage',
|
|
474
|
-
'work_item.request_closure',
|
|
475
|
-
'evidence.append',
|
|
476
|
-
'check.evaluate',
|
|
477
|
-
'proposal.decide',
|
|
478
|
-
'approval.decide',
|
|
479
|
-
]) {
|
|
480
|
-
capability.run(positionId, name, 'global')
|
|
481
|
-
}
|
|
482
|
-
const assigned = db
|
|
483
|
-
.prepare('SELECT 1 FROM member_position WHERE member_id = ? AND position_id = ?')
|
|
484
|
-
.get(memberId, positionId) as { 1: number } | undefined
|
|
485
|
-
if (!assigned) {
|
|
486
|
-
db.prepare(
|
|
487
|
-
`INSERT INTO member_position (member_id, position_id, starts_at, ends_at, assigned_by)
|
|
488
|
-
VALUES (?, ?, 0, NULL, 'system')`,
|
|
489
|
-
).run(memberId, positionId)
|
|
490
|
-
}
|
|
491
|
-
db.prepare(
|
|
492
|
-
`INSERT INTO hams_user (id, member_id, login, display_name, auth_source, external_id, is_admin, status, last_login_at)
|
|
493
|
-
VALUES ('user-local', ?, 'local', '本机操作者', 'local_host', 'local', 1, 'active', ?)`,
|
|
494
|
-
).run(memberId, now)
|
|
495
|
-
db.exec('COMMIT')
|
|
496
|
-
return memberId
|
|
497
|
-
} catch (error) {
|
|
498
|
-
if (db.isTransaction) db.exec('ROLLBACK')
|
|
499
|
-
throw error
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* 给本机负责人岗位补齐 M6 需要的能力。
|
|
505
|
-
*
|
|
506
|
-
* 为什么要单独一步:`ensureLocalOperator` 在已有 operator 时直接返回,老库不会
|
|
507
|
-
* 重新走一遍授权;而项目 / 成员 / 知识三屏的写命令需要 `project.create`、
|
|
508
|
-
* `member.manage` 等新能力。这里只对**自动创建的 `pos-owner` 岗位**补缺失项,
|
|
509
|
-
* 不碰用户自建岗位,也不覆盖已有的 scope。
|
|
510
|
-
*/
|
|
511
|
-
export const OPERATOR_CAPABILITIES = [
|
|
512
|
-
'work_item.create',
|
|
513
|
-
'work_item.accept',
|
|
514
|
-
'work_item.manage',
|
|
515
|
-
'work_item.request_closure',
|
|
516
|
-
'evidence.append',
|
|
517
|
-
'check.evaluate',
|
|
518
|
-
'proposal.decide',
|
|
519
|
-
'approval.decide',
|
|
520
|
-
'project.create',
|
|
521
|
-
'project.manage',
|
|
522
|
-
'project.archive',
|
|
523
|
-
'target.publish',
|
|
524
|
-
'review.confirm',
|
|
525
|
-
'metric.record',
|
|
526
|
-
'member.manage',
|
|
527
|
-
'knowledge.write',
|
|
528
|
-
'knowledge.sync',
|
|
529
|
-
] as const
|
|
530
|
-
|
|
531
|
-
export function ensureOperatorCapabilities(db: DatabaseSync, memberId: string): number {
|
|
532
|
-
const position = db.prepare("SELECT id FROM position WHERE id = 'pos-owner'").get() as
|
|
533
|
-
| { id: string }
|
|
534
|
-
| undefined
|
|
535
|
-
if (!position) return 0
|
|
536
|
-
const assigned = db
|
|
537
|
-
.prepare('SELECT 1 AS ok FROM member_position WHERE member_id = ? AND position_id = ? AND ends_at IS NULL')
|
|
538
|
-
.get(memberId, position.id) as { ok: number } | undefined
|
|
539
|
-
if (!assigned) return 0
|
|
540
|
-
const insert = db.prepare(
|
|
541
|
-
'INSERT OR IGNORE INTO position_permission (position_id, capability, scope) VALUES (?, ?, ?)',
|
|
542
|
-
)
|
|
543
|
-
let added = 0
|
|
544
|
-
for (const capability of OPERATOR_CAPABILITIES) added += Number(insert.run(position.id, capability, 'global').changes ?? 0)
|
|
545
|
-
return added
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function resolveDshHome(env: Record<string, string | undefined> = process.env): string {
|
|
549
|
-
const configured = env.DSH_HOME && env.DSH_HOME.trim() ? env.DSH_HOME.trim() : ''
|
|
550
|
-
return configured ? path.resolve(configured) : path.join(homedir(), '.dsh')
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function isLockError(error: unknown): boolean {
|
|
554
|
-
const code = (error as StoreError | undefined)?.code
|
|
555
|
-
return code === 'LOCK_HELD' || code === 'LOCK_HELD_SELF'
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* 在宿主里启动 HAMS:拿写锁;拿不到就降级只读。所有注册都返回 disposer。
|
|
560
|
-
*/
|
|
561
|
-
export function registerHams(ctx: HostContext, config: HamsHostConfig = {}): RegisteredHams {
|
|
562
|
-
const startedAt = Date.now()
|
|
563
|
-
const dataDir = resolveDataDir(config.dataDir)
|
|
564
|
-
|
|
565
|
-
let runtime: ReturnType<typeof bootstrapStore> | null = null
|
|
566
|
-
let reader: ReturnType<typeof openReadOnlyStore> | null = null
|
|
567
|
-
let mode: 'writer' | 'reader'
|
|
568
|
-
try {
|
|
569
|
-
runtime = bootstrapStore({ dataDir, backupKeep: config.backupKeep })
|
|
570
|
-
mode = 'writer'
|
|
571
|
-
} catch (error) {
|
|
572
|
-
if (!isLockError(error)) throw error
|
|
573
|
-
reader = openReadOnlyStore(dataDir)
|
|
574
|
-
mode = 'reader'
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const db = runtime?.db ?? reader!.db
|
|
578
|
-
const dshHome = resolveDshHome()
|
|
579
|
-
const knowledgeSkillDirs =
|
|
580
|
-
config.knowledgeSkillDirs && config.knowledgeSkillDirs.length
|
|
581
|
-
? [...config.knowledgeSkillDirs]
|
|
582
|
-
: [path.join(dshHome, 'skills')]
|
|
583
|
-
let starterTemplateVersionId: string | null = null
|
|
584
|
-
let operatorCapabilitiesAdded = 0
|
|
585
|
-
let workspaceProfile: WorkspaceProfileReport | null = null
|
|
586
|
-
let defaultBots: DefaultBotResult | null = null
|
|
587
|
-
if (mode === 'writer') {
|
|
588
|
-
const operatorId = ensureLocalOperator(db, startedAt)
|
|
589
|
-
operatorCapabilitiesAdded = ensureOperatorCapabilities(db, operatorId)
|
|
590
|
-
starterTemplateVersionId = ensureStarterTemplate(db, operatorId, startedAt)
|
|
591
|
-
// 默认 bot 先于画像落库:画像里的项目如果没指定推进 bot,也能拿到「项目管理」。
|
|
592
|
-
try {
|
|
593
|
-
defaultBots = ensureDefaultBots(db, createIdFactory(() => Date.now()), startedAt, operatorId)
|
|
594
|
-
} catch (error) {
|
|
595
|
-
ctx.logger?.warn?.(`[hams] 默认 bot 补齐失败:${String(error)}`)
|
|
596
|
-
}
|
|
597
|
-
workspaceProfile = applyWorkspaceProfile({
|
|
598
|
-
db,
|
|
599
|
-
dataDir,
|
|
600
|
-
operatorId,
|
|
601
|
-
now: startedAt,
|
|
602
|
-
idFactory: createIdFactory(() => Date.now()),
|
|
603
|
-
file: config.workspaceProfileFile?.trim() || defaultProfilePath(dataDir),
|
|
604
|
-
workspaceRoot: config.workspaceRoot?.trim() || undefined,
|
|
605
|
-
skillDirs: knowledgeSkillDirs,
|
|
606
|
-
})
|
|
607
|
-
}
|
|
608
|
-
if (mode === 'writer') {
|
|
609
|
-
// 历史项目补齐「默认阶段 + 首个目标」:界面永远有可推进的骨架。
|
|
610
|
-
try { ensureAllDefaultPlans(db, createIdFactory(() => Date.now()), Date.now()) } catch (error) { ctx.logger?.warn?.(`[hams] 补默认阶段失败:${String(error)}`) }
|
|
611
|
-
}
|
|
612
|
-
const service = new HamsService({
|
|
613
|
-
db,
|
|
614
|
-
runtimeEpoch: runtime?.report.runtimeEpoch ?? readRuntimeEpoch(db),
|
|
615
|
-
knowledge: { dataDir, skillDirs: knowledgeSkillDirs },
|
|
616
|
-
})
|
|
617
|
-
// 对话:只在写者实例接宿主 agent 服务(只读实例没有写权限存会话指针)。
|
|
618
|
-
const chatRuntime =
|
|
619
|
-
mode === 'writer'
|
|
620
|
-
? createHostChatRuntime({ ctx, dataDir, log: (line) => ctx.logger?.warn?.(line) })
|
|
621
|
-
: null
|
|
622
|
-
const chat = chatRuntime ? new ChatService({ db, runtime: chatRuntime }) : null
|
|
623
|
-
const disposers: HostDisposer[] = []
|
|
624
|
-
|
|
625
|
-
// bot 运行时:把工作项线程接到真实 agent 会话;服务缺失时只记日志,不影响面板启动。
|
|
626
|
-
const hostService = <T>(name: string): T | undefined => {
|
|
627
|
-
if (typeof ctx.get !== 'function') return undefined
|
|
628
|
-
try {
|
|
629
|
-
const found = ctx.get(name)
|
|
630
|
-
return found && typeof found === 'object' ? (found as T) : undefined
|
|
631
|
-
} catch {
|
|
632
|
-
return undefined
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
let sessionAdapter: HamsSessionAdapter | null = null
|
|
636
|
-
if (mode === 'writer' && config.botRuntime !== false) {
|
|
637
|
-
const agentsProxy = {
|
|
638
|
-
async create(options: Record<string, unknown>) {
|
|
639
|
-
const agents = hostService<{ create(o: Record<string, unknown>): Promise<unknown> }>('agents')
|
|
640
|
-
if (!agents) throw new Error('宿主没有 agents 服务')
|
|
641
|
-
return (await agents.create(options)) as never
|
|
642
|
-
},
|
|
643
|
-
async resume(options: Record<string, unknown>) {
|
|
644
|
-
const agents = hostService<{ resume(o: Record<string, unknown>): Promise<unknown> }>('agents')
|
|
645
|
-
if (!agents) throw new Error('宿主没有 agents 服务')
|
|
646
|
-
return (await agents.resume(options)) as never
|
|
647
|
-
},
|
|
648
|
-
}
|
|
649
|
-
sessionAdapter = new HamsSessionAdapter({
|
|
650
|
-
db,
|
|
651
|
-
service,
|
|
652
|
-
dataDir,
|
|
653
|
-
defaultPreset: HAMS_BOT_PRESET,
|
|
654
|
-
agents: agentsProxy as never,
|
|
655
|
-
createUserMessage: (input: Record<string, unknown>) => ({ id: `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`, role: 'user', ...input }),
|
|
656
|
-
compose: async (bot) => {
|
|
657
|
-
const preset = bot.agentPreset && bot.agentPreset !== HAMS_BOT_PRESET ? bot.agentPreset : await ensureHamsBotPreset(ctx, (line) => ctx.logger?.warn?.(line))
|
|
658
|
-
// bot 没配模型时用宿主默认模型兜底(否则会话一开就报 has no provider/model)。
|
|
659
|
-
const selection = hostService<{ currentSelection?: () => { provider?: string; model?: string; reasoningEffort?: string } }>('agentDefaultModel')?.currentSelection?.()
|
|
660
|
-
const model = await resolveAgentOptions(bot.model, hostService('llm') as never, selection)
|
|
661
|
-
if (!model) {
|
|
662
|
-
throw new HamsError(
|
|
663
|
-
'HOST_CAPABILITY_MISSING',
|
|
664
|
-
`bot ${bot.memberId} 没有配置模型,宿主也没有默认模型`,
|
|
665
|
-
{ nextStep: '在成员配置里给这个 bot 选一个模型,或在宿主设置里选一个默认模型' },
|
|
666
|
-
)
|
|
667
|
-
}
|
|
668
|
-
const registry = hostService<{ mount(agentCtx: unknown, id: string): Promise<unknown> }>('agentPresets')
|
|
669
|
-
const setup = registry && preset ? async (agentCtx: unknown) => { await registry.mount(agentCtx, preset) } : undefined
|
|
670
|
-
return { agentOptions: model ? { ...model } : {}, ...(setup ? { setup } : {}), agentPreset: preset }
|
|
671
|
-
},
|
|
672
|
-
})
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
// 宿主 connection 可能在插件 apply 之后才激活:每次请求现取,别在启动时缓存。
|
|
677
|
-
// 只走 ctx.get():Cordis 里直接读 ctx.connection 会抛「without inject」。
|
|
678
|
-
const resolveConnection = (): HostConnection | undefined => {
|
|
679
|
-
if (config.connection) return config.connection
|
|
680
|
-
if (typeof ctx.get !== 'function') return undefined
|
|
681
|
-
try {
|
|
682
|
-
const found = ctx.get('connection')
|
|
683
|
-
return found && typeof found === 'object' ? (found as HostConnection) : undefined
|
|
684
|
-
} catch {
|
|
685
|
-
return undefined
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
const sessionMode = config.hostSession ?? 'auto'
|
|
689
|
-
const sessionState: HostSessionState =
|
|
690
|
-
sessionMode === 'off' ? 'off' : resolveConnection() ? 'host' : 'origin-only'
|
|
691
|
-
|
|
692
|
-
let ticker: TickerHandle | null = null
|
|
693
|
-
if (mode === 'writer' && config.tickEnabled !== false) {
|
|
694
|
-
// 启动对账:上一个进程留下的 queued/running 运行必须收尾,否则会一直占着 uq_wi_active_run,
|
|
695
|
-
// 被指派的 bot 永远等不到新一轮。
|
|
696
|
-
const recovered = recover({ db, runtimeEpoch: runtime!.report.runtimeEpoch, dataDir })
|
|
697
|
-
if (recovered.cancelledQueuedRuns || recovered.orphanedRunningRuns) {
|
|
698
|
-
ctx.logger?.warn?.(`[hams] 启动对账:取消排队 ${recovered.cancelledQueuedRuns} 个 / 标记孤儿 ${recovered.orphanedRunningRuns} 个`)
|
|
699
|
-
}
|
|
700
|
-
ticker = createTicker({
|
|
701
|
-
db,
|
|
702
|
-
runtimeEpoch: runtime!.report.runtimeEpoch,
|
|
703
|
-
intervalMs: config.tickMs ?? 5_000,
|
|
704
|
-
dataDir,
|
|
705
|
-
})
|
|
706
|
-
ticker.start()
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// 派发:到期复查只把 run 排队;这里负责真正把 queued 运行与「已指派的任务」投给 bot 会话。
|
|
710
|
-
let dispatchTimer: ReturnType<typeof setInterval> | null = null
|
|
711
|
-
if (sessionAdapter) {
|
|
712
|
-
let dispatching = false
|
|
713
|
-
dispatchTimer = setInterval(() => {
|
|
714
|
-
if (dispatching) return
|
|
715
|
-
dispatching = true
|
|
716
|
-
Promise.resolve()
|
|
717
|
-
.then(() => sessionAdapter!.dispatchAssignments())
|
|
718
|
-
.then((report) => { if (report.errors.length) ctx.logger?.warn?.(`[hams] 指派派发失败:${report.errors.slice(0, 3).join(' | ')}`) })
|
|
719
|
-
.then(() => sessionAdapter!.dispatchQueued())
|
|
720
|
-
.catch((error) => ctx.logger?.warn?.(`[hams] 派发失败:${String(error)}`))
|
|
721
|
-
.finally(() => { dispatching = false })
|
|
722
|
-
}, Math.max(3000, config.tickMs ?? 5_000))
|
|
723
|
-
if (typeof dispatchTimer === 'object' && dispatchTimer && 'unref' in dispatchTimer) dispatchTimer.unref()
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
const routePaths = ROUTE_PATHS.map((path) => `${API_PREFIX}${path}`)
|
|
727
|
-
let toolNames: string[] = []
|
|
728
|
-
try {
|
|
729
|
-
if (config.toolsEnabled !== false && ctx.tools) {
|
|
730
|
-
const definitions = createHamsTools({
|
|
731
|
-
service,
|
|
732
|
-
db,
|
|
733
|
-
resolveSessionOwner: config.defaultBotMemberId
|
|
734
|
-
? () => ({ memberId: config.defaultBotMemberId as string })
|
|
735
|
-
: undefined,
|
|
736
|
-
...(chat
|
|
737
|
-
? {
|
|
738
|
-
chat: {
|
|
739
|
-
projectOfSession: (sessionId: string) => chat.projectOfSession(sessionId),
|
|
740
|
-
groupMembersBySession: (sessionId: string) => chat.groupMembersBySession(sessionId),
|
|
741
|
-
groupSpeakBySession: (sessionId: string, input) => chat.groupSpeakBySession(sessionId, input),
|
|
742
|
-
groupSpawnBySession: (sessionId: string, input) => chat.groupSpawnBySession(sessionId, input),
|
|
743
|
-
noteAssignment: (sessionId: string, input) => chat.groupNoteAssignmentBySession(sessionId, input),
|
|
744
|
-
rememberBySession: (sessionId: string, input) => chat.rememberBySession(sessionId, input),
|
|
745
|
-
},
|
|
746
|
-
}
|
|
747
|
-
: {}),
|
|
748
|
-
})
|
|
749
|
-
const hostTools = toHostTools(definitions)
|
|
750
|
-
for (const tool of hostTools) {
|
|
751
|
-
const disposer = ctx.tools.register(tool)
|
|
752
|
-
if (typeof disposer === 'function') disposers.push(disposer as HostDisposer)
|
|
753
|
-
}
|
|
754
|
-
toolNames = definitions.map((definition) => definition.name)
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
const handler = createHamsHttpHandler({
|
|
758
|
-
service,
|
|
759
|
-
authenticate: createLocalAuthenticator(db, config, resolveConnection),
|
|
760
|
-
readOnly: mode === 'reader',
|
|
761
|
-
...(chat ? { chat } : {}),
|
|
762
|
-
deliverables: { dataDir, db },
|
|
763
|
-
})
|
|
764
|
-
|
|
765
|
-
for (const path of routePaths) {
|
|
766
|
-
const disposer = ctx.webServer.register({
|
|
767
|
-
kind: 'exact',
|
|
768
|
-
path,
|
|
769
|
-
handler: async (req, res) => {
|
|
770
|
-
try {
|
|
771
|
-
const body = await readRequestBody(req)
|
|
772
|
-
const response = await handler({
|
|
773
|
-
method: req.method ?? 'POST',
|
|
774
|
-
path,
|
|
775
|
-
headers: toHeaderRecord(req.headers),
|
|
776
|
-
body,
|
|
777
|
-
})
|
|
778
|
-
writeResponse(res, response)
|
|
779
|
-
} catch (error) {
|
|
780
|
-
writeResponse(res, {
|
|
781
|
-
status: 400,
|
|
782
|
-
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
783
|
-
body: JSON.stringify({ ok: false, error: { code: 'STATE_INVALID', message: String((error as Error)?.message ?? error), nextStep: '检查请求体大小后重试' } }),
|
|
784
|
-
})
|
|
785
|
-
}
|
|
786
|
-
},
|
|
787
|
-
})
|
|
788
|
-
if (typeof disposer === 'function') disposers.push(disposer)
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
} catch (error) {
|
|
792
|
-
// 注册到一半失败:把已经建好的路由、工具、tick 与连接全部收回,再抛出。
|
|
793
|
-
while (disposers.length) {
|
|
794
|
-
try {
|
|
795
|
-
disposers.pop()?.()
|
|
796
|
-
} catch {
|
|
797
|
-
// 忽略注销失败,继续清理。
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
ticker?.stop()
|
|
801
|
-
reader?.close()
|
|
802
|
-
runtime?.close()
|
|
803
|
-
throw error
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
ctx.logger?.info?.(
|
|
807
|
-
`[hams] ${mode === 'writer' ? '写者' : '只读'} 模式启动:${routePaths.length} 条路由 / ${toolNames.length} 个工具,用时 ${Date.now() - startedAt}ms,数据目录 ${dataDir},会话校验 ${sessionState}`,
|
|
808
|
-
)
|
|
809
|
-
if (workspaceProfile && workspaceProfile.status !== 'missing') {
|
|
810
|
-
const summary = workspaceProfile.created
|
|
811
|
-
const line = `[hams] 工作区画像 ${workspaceProfile.status}:成员 ${summary.members} / 项目 ${summary.projects} / 工作项 ${summary.workItems} / 文档 ${summary.docs} / 技能 ${summary.skills} / 表 ${summary.tables} / 清理模板 ${summary.removedTemplates}${workspaceProfile.errors.length ? ` / 错误 ${workspaceProfile.errors.length}` : ''}${workspaceProfile.warnings.length ? ` / 告警 ${workspaceProfile.warnings.length}` : ''}`
|
|
812
|
-
if (workspaceProfile.status === 'failed' || workspaceProfile.errors.length) ctx.logger?.warn?.(line)
|
|
813
|
-
else ctx.logger?.info?.(line)
|
|
814
|
-
for (const error of workspaceProfile.errors.slice(0, 5)) ctx.logger?.warn?.(`[hams] 画像:${error}`)
|
|
815
|
-
for (const warning of workspaceProfile.warnings.slice(0, 5)) ctx.logger?.warn?.(`[hams] 画像告警:${warning}`)
|
|
816
|
-
}
|
|
817
|
-
if (sessionState === 'origin-only') {
|
|
818
|
-
ctx.logger?.warn?.(
|
|
819
|
-
'[hams] 宿主没有提供 connection 服务:界面接口只验 Origin,不校验宿主浏览器会话。' +
|
|
820
|
-
'要强制校验,把配置 hostSession 设为 require(或在带 Web 载体的宿主里运行)。',
|
|
821
|
-
)
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
const registered: RegisteredHams = {
|
|
825
|
-
mode,
|
|
826
|
-
service,
|
|
827
|
-
db,
|
|
828
|
-
report: runtime?.report ?? null,
|
|
829
|
-
ticker,
|
|
830
|
-
routes: routePaths,
|
|
831
|
-
toolNames,
|
|
832
|
-
sessionState,
|
|
833
|
-
operatorCapabilitiesAdded,
|
|
834
|
-
workspaceProfile,
|
|
835
|
-
defaultBots,
|
|
836
|
-
dispose() {
|
|
837
|
-
while (disposers.length) {
|
|
838
|
-
const dispose = disposers.pop()
|
|
839
|
-
try {
|
|
840
|
-
dispose?.()
|
|
841
|
-
} catch {
|
|
842
|
-
// 注销失败不能阻碍释放锁。
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
ticker?.stop()
|
|
846
|
-
if (dispatchTimer) clearInterval(dispatchTimer)
|
|
847
|
-
void sessionAdapter?.dispose()
|
|
848
|
-
reader?.close()
|
|
849
|
-
runtime?.close()
|
|
850
|
-
void chatRuntime?.dispose()
|
|
851
|
-
},
|
|
852
|
-
}
|
|
853
|
-
return registered
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/** 插件入口调用:优先用 ctx.effect 托管生命周期,拿不到就同步注册。 */
|
|
857
|
-
export function applyHams(ctx: HostContext, config: HamsHostConfig = {}): RegisteredHams | null {
|
|
858
|
-
if (typeof ctx.effect === 'function') {
|
|
859
|
-
let registered: RegisteredHams | null = null
|
|
860
|
-
ctx.effect(() => {
|
|
861
|
-
registered = registerHams(ctx, config)
|
|
862
|
-
return () => registered?.dispose()
|
|
863
|
-
}, 'hams.runtime')
|
|
864
|
-
return registered
|
|
865
|
-
}
|
|
866
|
-
return registerHams(ctx, config)
|
|
867
|
-
}
|