@twinsearth/dsh-master 0.0.0-stage → 3.2.9

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.
Files changed (97) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +93 -2
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +389 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/RELEASE-v3.2.8.md +27 -0
  45. package/docs/RELEASE-v3.2.9.md +29 -0
  46. package/docs/REVIEW-v3.2.7.md +121 -0
  47. package/docs/ROADMAP.md +106 -0
  48. package/docs/SECURITY.md +153 -0
  49. package/docs/SMOKE-3.2.5.md +77 -0
  50. package/docs/SMOKE-3.2.7.md +73 -0
  51. package/docs/SUPERAGENT.md +141 -0
  52. package/docs/WORKER-AGENT-LOOP.md +139 -0
  53. package/icon.svg +9 -0
  54. package/locale/en.json +6 -0
  55. package/locale/zh.json +6 -0
  56. package/package.json +79 -4
  57. package/src/contract/client.js +86 -0
  58. package/src/contract/constants.js +79 -0
  59. package/src/contract/envelope.js +83 -0
  60. package/src/contract/index.js +19 -0
  61. package/src/contract/negotiate.js +106 -0
  62. package/src/contract/server.js +77 -0
  63. package/src/contract/transports.js +319 -0
  64. package/src/index.ts +265 -0
  65. package/src/kernel.ts +190 -0
  66. package/src/plugins/cost-optimizer.ts +155 -0
  67. package/src/plugins/dual-manager.ts +252 -0
  68. package/src/plugins/superagent-router.ts +465 -0
  69. package/src/plugins/superagent-worker.ts +85 -0
  70. package/src/superagent/bridge-tokens.ts +236 -0
  71. package/src/superagent/dispatch.ts +197 -0
  72. package/src/superagent/feishu.ts +104 -0
  73. package/src/superagent/github.ts +111 -0
  74. package/src/superagent/pr-delivery.ts +308 -0
  75. package/src/superagent/router.ts +201 -0
  76. package/src/superagent/stream-adapter.ts +184 -0
  77. package/src/superagent/stream-tokens.ts +142 -0
  78. package/src/superagent/ticket-store.ts +104 -0
  79. package/src/superagent/trust.ts +139 -0
  80. package/src/superagent/types.ts +135 -0
  81. package/src/superagent/worker.ts +100 -0
  82. package/src/superagent/writeback.ts +154 -0
  83. package/tsconfig.host.build.json +12 -0
  84. package/tsconfig.host.json +21 -0
  85. package/tsconfig.worker.build.json +13 -0
  86. package/tsconfig.worker.json +17 -0
  87. package/types/superagent.d.ts +99 -0
  88. package/worker/agent-loop.ts +524 -0
  89. package/worker/errors.ts +47 -0
  90. package/worker/github-pr.ts +65 -0
  91. package/worker/index.ts +334 -0
  92. package/worker/llm-http.ts +359 -0
  93. package/worker/main.ts +45 -0
  94. package/worker/prompt.ts +39 -0
  95. package/worker/sink-http.ts +159 -0
  96. package/worker/tools.ts +531 -0
  97. package/worker/types.ts +247 -0
@@ -0,0 +1,308 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * PR 交付通道(v3.2.5)。
7
+ *
8
+ * 安全边界:**Worker 永不持有 push 凭据**。分支必须已由宿主/分身 git 凭据推送到远端;
9
+ * 本模块只在拿到 `{branch, title, body}` 后用宿主配置的 token 调用 GitHub API 创建 PR,
10
+ * 并可用 `githubContentsPush()` 让宿主自己把文件推进一个新分支(token 只在宿主进程内)。
11
+ */
12
+
13
+ const DEFAULT_API = 'https://api.github.com'
14
+ const MAX_FILES = 30
15
+ const MAX_TOTAL_BYTES = 2 * 1024 * 1024
16
+ const PROTECTED = ['main', 'master', 'trunk', 'release', 'production', 'stable', 'develop']
17
+
18
+ /** 统一读取响应体文本:真实 fetch 有 text(),测试替身/异常实现可能没有。 */
19
+ async function readBody(response) {
20
+ try {
21
+ if (response && typeof response.text === 'function') return await response.text()
22
+ if (response && typeof response.json === 'function') return JSON.stringify(await response.json())
23
+ } catch { /* 读取失败按空处理 */ }
24
+ return ''
25
+ }
26
+
27
+ function trim(value) {
28
+ return typeof value === 'string' ? value.trim() : ''
29
+ }
30
+
31
+ function splitRepo(repo) {
32
+ const text = trim(repo)
33
+ const parts = text.split('/')
34
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null
35
+ return { owner: parts[0], name: parts[1] }
36
+ }
37
+
38
+ /**
39
+ * @param {{token?:string, repo?:string, apiBase?:string, baseBranch?:string, fetchImpl?:Function, tokenHeader?:'bearer'|'token'}} [options]
40
+ */
41
+ export function createPrDelivery(options = {}) {
42
+ const token = trim(options.token)
43
+ const repo = trim(options.repo)
44
+ const apiBase = (trim(options.apiBase) || DEFAULT_API).replace(/\/+$/, '')
45
+ const baseBranch = trim(options.baseBranch) || 'main'
46
+ const rawFetch = options.fetchImpl ?? fetch
47
+ const requestTimeoutMs = Math.max(20, Number(options.requestTimeoutMs ?? 15_000) || 15_000)
48
+ const fetchImpl = async (url, init = {}) => {
49
+ const controller = new AbortController()
50
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs)
51
+ timer.unref?.()
52
+ try { return await rawFetch(url, { ...init, signal: controller.signal }) }
53
+ finally { clearTimeout(timer) }
54
+ }
55
+ const repoParts = splitRepo(repo)
56
+ const enabled = Boolean(token) && repoParts !== null
57
+
58
+ const headersOf = () => ({
59
+ 'content-type': 'application/json',
60
+ accept: 'application/vnd.github+json',
61
+ authorization: `Bearer ${token}`,
62
+ 'user-agent': 'dsh-master-router',
63
+ })
64
+
65
+
66
+ const branchError = (value, { allowProtected = false } = {}) => {
67
+ const name = trim(value)
68
+ if (!name || name.length > 200) return 'pr-branch-invalid'
69
+ if (name.includes(':') || name.startsWith('/') || name.endsWith('/') || name.includes('..') || name.includes('//')) return 'pr-branch-invalid'
70
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) || /[@~^\s?*\[\\]/.test(name) || name.endsWith('.lock')) return 'pr-branch-invalid'
71
+ if (!allowProtected && isProtectedBranch(name)) return 'pr-protected-branch'
72
+ return ''
73
+ }
74
+
75
+ const create = async (input = {}) => {
76
+ if (!enabled) return { ok: false, code: 'pr-disabled', error: 'github token/repo not configured' }
77
+ const branch = trim(input.branch)
78
+ const title = trim(input.title)
79
+ if (!branch) return { ok: false, code: 'pr-branch-required', error: 'branch must already be pushed' }
80
+ if (!title) return { ok: false, code: 'pr-title-required', error: 'title is required' }
81
+ const headError = branchError(branch)
82
+ if (headError) return { ok: false, code: headError, error: 'invalid or protected head branch' }
83
+ const base = trim(input.base) || baseBranch
84
+ if (isProtectedBranch(base) === false) return { ok: false, code: 'pr-base-not-trunk', error: 'base must be a protected trunk branch' }
85
+ try {
86
+ const response = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/pulls`, {
87
+ method: 'POST',
88
+ headers: headersOf(),
89
+ body: JSON.stringify({
90
+ title: title.slice(0, 200),
91
+ body: String(input.body ?? '').slice(0, 20_000),
92
+ head: branch,
93
+ base,
94
+ draft: input.draft === true,
95
+ }),
96
+ })
97
+ const text = await readBody(response)
98
+ if (!response.ok) {
99
+ return { ok: false, code: `pr-http-${response.status}`, error: text.slice(0, 200) }
100
+ }
101
+ let parsed = {}
102
+ try { parsed = JSON.parse(text) } catch { /* 保留兜底 */ }
103
+ return { ok: true, url: String(parsed.html_url ?? ''), number: Number(parsed.number ?? 0) }
104
+ } catch (error) {
105
+ return { ok: false, code: 'pr-network-error', error: String(error instanceof Error ? error.message : error) }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 宿主侧推送单个文件到新分支(Worker 没有凭据,只有宿主能调用)。
111
+ * @param {{branch:string, path:string, content:string, message?:string, base?:string}} input
112
+ */
113
+ const pushFile = async (input = {}) => {
114
+ if (!enabled) return { ok: false, code: 'pr-disabled', error: 'github token/repo not configured' }
115
+ const branch = trim(input.branch)
116
+ const filePath = trim(input.path)
117
+ if (!branch || !filePath) return { ok: false, code: 'push-input-invalid', error: 'branch and path are required' }
118
+ const headError = branchError(branch)
119
+ if (headError) return { ok: false, code: headError, error: 'invalid or protected push branch' }
120
+ const checkedPath = sanitizeRepoPath(filePath)
121
+ if (!checkedPath.ok) return { ok: false, code: checkedPath.code, error: 'path is outside the repository allowlist' }
122
+ try {
123
+ const base = trim(input.base) || baseBranch
124
+ const refResponse = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/git/ref/heads/${encodeURIComponent(base)}`, { headers: headersOf() })
125
+ if (!refResponse.ok) return { ok: false, code: 'push-base-missing', error: `base branch ${base} not readable` }
126
+ const ref = await refResponse.json()
127
+ const baseSha = String(ref?.object?.sha ?? '')
128
+ if (!baseSha) return { ok: false, code: 'push-base-sha-missing', error: 'base sha not found' }
129
+
130
+ const branchRef = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/git/ref/heads/${encodeURIComponent(branch)}`, { headers: headersOf() })
131
+ if (!branchRef.ok) {
132
+ const created = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/git/refs`, {
133
+ method: 'POST', headers: headersOf(), body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }),
134
+ })
135
+ if (!created.ok) {
136
+ const detail = await readBody(created)
137
+ return { ok: false, code: 'push-branch-create-failed', error: detail.slice(0, 200) }
138
+ }
139
+ }
140
+ const put = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/contents/${checkedPath.path.split('/').map(encodeURIComponent).join('/')}`, {
141
+ method: 'PUT',
142
+ headers: headersOf(),
143
+ body: JSON.stringify({
144
+ message: trim(input.message) || `chore: update ${filePath}`,
145
+ content: Buffer.from(String(input.content ?? ''), 'utf8').toString('base64'),
146
+ branch,
147
+ }),
148
+ })
149
+ const putText = await readBody(put)
150
+ if (!put.ok) return { ok: false, code: `push-http-${put.status}`, error: putText.slice(0, 200) }
151
+ return { ok: true, branch, path: filePath }
152
+ } catch (error) {
153
+ return { ok: false, code: 'push-network-error', error: String(error instanceof Error ? error.message : error) }
154
+ }
155
+ }
156
+
157
+ /** 受保护分支(不能作为 PR 的 head;也不能作为推送目标)。 */
158
+ const isProtectedBranch = (name) => PROTECTED.includes(String(trim(name)).toLowerCase())
159
+
160
+ /**
161
+ * 分支命名策略(v3.2.7):`<prefix>/<source>-<ticket>-<hash8>`。
162
+ * * prefix 默认 `superagent`,可配置;
163
+ * * source/ticket 做字符白名单 + 长度截断,避免注入或超长 ref;
164
+ * * 末尾 8 位 hash 保证同一 ticket 的重复交付落到同一分支(幂等)。
165
+ */
166
+ const branchForTicket = (ticketId, source) => {
167
+ const prefix = (trim(options.branchPrefix) || 'superagent').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '')
168
+ const safe = (value, max) => String(trim(value) || 'na').toLowerCase().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, max) || 'na'
169
+ const sourceToken = safe(String(source ?? '').split(/[:#/]/)[0], 16)
170
+ const ticket = safe(ticketId, 40)
171
+ const hash = String(options.hashOf ? options.hashOf(String(ticketId ?? '')) : ticket).slice(0, 8).replace(/[^A-Za-z0-9]/g, '0')
172
+ const branch = `${prefix}/${sourceToken}-${ticket}-${hash}`
173
+ return branch
174
+ }
175
+
176
+ /** 仓库内路径白名单:禁止绝对路径、..、.git、CI 工作流。 */
177
+ const sanitizeRepoPath = (value) => {
178
+ if (/\u0000-\u001f/.test(String(value ?? ''))) return { ok: false, code: 'path-control' }
179
+ const raw = String(trim(value) ?? '').replace(/\\/g, '/')
180
+ if (!raw) return { ok: false, code: 'path-empty' }
181
+ if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) return { ok: false, code: 'path-absolute' }
182
+ const parts = raw.split('/')
183
+ if (parts.some((part) => part === '..' || part === '' || part === '.')) return { ok: false, code: 'path-traversal' }
184
+ if (parts.some((part) => part.toLowerCase() === '.git')) return { ok: false, code: 'path-git' }
185
+ if (parts[0] === '.github' && parts[1] === 'workflows') return { ok: false, code: 'path-workflow' }
186
+ return { ok: true, path: parts.join('/') }
187
+ }
188
+
189
+ const refSha = async (branch) => {
190
+ const res = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/git/ref/heads/${encodeURIComponent(branch)}`, { headers: headersOf() })
191
+ if (!res.ok) return null
192
+ try { const body = await res.json(); return String(body?.object?.sha ?? '') || null } catch { return null }
193
+ }
194
+
195
+ const ensureBranch = async (branch, base) => {
196
+ const sha = await refSha(branch)
197
+ if (sha) return { ok: true, created: false }
198
+ const baseSha = await refSha(base)
199
+ if (!baseSha) return { ok: false, code: 'push-base-missing', error: `base branch ${base} not readable` }
200
+ const created = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/git/refs`, {
201
+ method: 'POST', headers: headersOf(), body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }),
202
+ })
203
+ if (!created.ok) {
204
+ const detail = await readBody(created)
205
+ // 并发场景下可能已被创建:再查一次
206
+ if (await refSha(branch)) return { ok: true, created: false }
207
+ return { ok: false, code: 'push-branch-create-failed', error: detail.slice(0, 200) }
208
+ }
209
+ return { ok: true, created: true }
210
+ }
211
+
212
+ /** 幂等 PR:先查 open PR(head=branch),已存在则复用。 */
213
+ const findOpenPr = async (branch) => {
214
+ const res = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/pulls?state=open&head=${encodeURIComponent(`${repoParts.owner}:${branch}`)}`, { headers: headersOf() })
215
+ if (!res.ok) return null
216
+ try {
217
+ const list = await res.json()
218
+ const item = Array.isArray(list) ? list[0] : null
219
+ return item ? { url: String(item.html_url ?? ''), number: Number(item.number ?? 0) } : null
220
+ } catch { return null }
221
+ }
222
+
223
+ const putFile = async (branch, repoPath, content, message) => {
224
+ const encoded = repoPath.split('/').map(encodeURIComponent).join('/')
225
+ // 已存在的文件必须带 sha,否则 GitHub 返回 422
226
+ const existing = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/contents/${encoded}?ref=${encodeURIComponent(branch)}`, { headers: headersOf() })
227
+ let sha = ''
228
+ if (existing.ok) {
229
+ try { sha = String((await existing.json())?.sha ?? '') } catch { sha = '' }
230
+ }
231
+ const put = await fetchImpl(`${apiBase}/repos/${repoParts.owner}/${repoParts.name}/contents/${encoded}`, {
232
+ method: 'PUT',
233
+ headers: headersOf(),
234
+ body: JSON.stringify({
235
+ message: trim(message) || `chore: ${repoPath}`,
236
+ content: Buffer.from(String(content ?? ''), 'utf8').toString('base64'),
237
+ branch,
238
+ ...(sha ? { sha } : {}),
239
+ }),
240
+ })
241
+ const detail = await readBody(put)
242
+ if (!put.ok) return { ok: false, code: `push-http-${put.status}`, error: detail.slice(0, 200) }
243
+ return { ok: true }
244
+ }
245
+
246
+ /**
247
+ * 自动交付(v3.2.7):**分支由宿主推送**(Worker 无凭据)→ 建分支 → 逐文件提交 → 开 PR。
248
+ * 幂等:分支复用、已存在 open PR 直接复用,不重复开单。
249
+ *
250
+ * @param {{ticketId:string, source?:string, files:Array<{path:string, content:string}>,
251
+ * title?:string, body?:string, base?:string, branch?:string, draft?:boolean}} input
252
+ */
253
+ const deliver = async (input = {}) => {
254
+ if (!enabled) return { ok: false, code: 'pr-disabled', error: 'github token/repo not configured' }
255
+ const files = Array.isArray(input.files) ? input.files : []
256
+ if (files.length === 0) return { ok: false, code: 'pr-no-files', error: 'no changed files to push' }
257
+ if (files.length > MAX_FILES) return { ok: false, code: 'pr-too-many-files', error: `${files.length} files exceeds ${MAX_FILES}` }
258
+ const branch = trim(input.branch) || branchForTicket(input.ticketId, input.source)
259
+ const headError = branchError(branch)
260
+ if (headError) return { ok: false, code: headError, error: 'invalid or protected delivery branch' }
261
+ const base = trim(input.base) || baseBranch
262
+ if (isProtectedBranch(base) === false) return { ok: false, code: 'pr-base-not-trunk', error: `base must be a protected trunk branch, got ${base}` }
263
+
264
+ const accepted = []
265
+ let total = 0
266
+ for (const file of files) {
267
+ const checked = sanitizeRepoPath(file?.path)
268
+ if (!checked.ok) return { ok: false, code: `pr-file-${checked.code}`, error: String(file?.path ?? '') }
269
+ const content = String(file?.content ?? '')
270
+ total += Buffer.byteLength(content, 'utf8')
271
+ if (total > MAX_TOTAL_BYTES) return { ok: false, code: 'pr-files-too-large', error: `payload exceeds ${MAX_TOTAL_BYTES} bytes` }
272
+ accepted.push({ path: checked.path, content })
273
+ }
274
+
275
+ const branchState = await ensureBranch(branch, base)
276
+ if (!branchState.ok) return branchState
277
+ for (const file of accepted) {
278
+ const pushed = await putFile(branch, file.path, file.content, trim(input.message) || `chore(superagent): ${input.ticketId ?? 'ticket'}`)
279
+ if (!pushed.ok) return { ...pushed, branch }
280
+ }
281
+ const existing = await findOpenPr(branch)
282
+ if (existing) return { ok: true, branch, url: existing.url, number: existing.number, reused: true, pushedFiles: accepted.map((f) => f.path) }
283
+ const pr = await create({ branch, title: trim(input.title) || `SuperAgent: ${input.ticketId ?? branch}`, body: input.body, base, draft: input.draft === true })
284
+ if (!pr.ok) return { ...pr, branch }
285
+ return { ok: true, branch, url: pr.url, number: pr.number, pushedFiles: accepted.map((f) => f.path) }
286
+ }
287
+
288
+ return {
289
+ enabled,
290
+ create,
291
+ sanitizeRepoPath,
292
+ isProtectedBranch,
293
+ pushFile,
294
+ deliver,
295
+ branchForTicket,
296
+ describe: () => ({
297
+ enabled,
298
+ repo: enabled ? repo : null,
299
+ apiBase,
300
+ baseBranch,
301
+ workerHoldsCredentials: false,
302
+ mode: 'host-pushes-branch-then-pr',
303
+ branchPrefix: trim(options.branchPrefix) || 'superagent',
304
+ maxFiles: MAX_FILES,
305
+ maxTotalBytes: MAX_TOTAL_BYTES,
306
+ }),
307
+ }
308
+ }
@@ -0,0 +1,201 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * SuperAgent Router(v3.2.1):事件 → 评估 → Ticket/Mandate → 派发 Worker。
7
+ *
8
+ * Router 常驻原身(essence),**不执行代码改动**:所有执行体通过 dispatching
9
+ * 适配器交给瓮城 fangForker.spawn(分身),本模块不做文件/进程操作。
10
+ * 纯核心逻辑(评估/建票)在 trust.mjs;本模块只做编排、去重、持久化与审计。
11
+ */
12
+
13
+ import { evaluateEvent, normalizePolicy, describePolicy } from './trust.ts'
14
+ import { publicTicket } from './types.ts'
15
+ import { handleGithubWebhook } from './github.ts'
16
+ import { handleFeishuWebhook } from './feishu.ts'
17
+
18
+ const RECENT_LIMIT = 50
19
+ const DEDUPE_LIMIT = 500
20
+
21
+ /**
22
+ * @param {{
23
+ * config?: object,
24
+ * store: ReturnType<import('./ticket-store.ts').createTicketStore>,
25
+ * dispatcher: {available:()=>boolean, describe:()=>(object), dispatch:(ticket:object)=>Promise<object>},
26
+ * logger?: {info?:Function,warn?:Function},
27
+ * }} options
28
+ */
29
+ export function createSuperagentRouter({ config = {}, store, dispatcher, logger = null, beforeDispatch = null } = {}) {
30
+ if (!store || typeof store.add !== 'function') throw new Error('superagent router: ticket store is required')
31
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') throw new Error('superagent router: dispatcher is required')
32
+ const basePolicy = normalizePolicy(config)
33
+ let trustMode = basePolicy.trustMode
34
+ let policy = Object.freeze({ ...basePolicy, trustMode })
35
+ const counters = {
36
+ received: 0, duplicate: 0, ignored: 0, observed: 0, accepted: 0, unavailable: 0, dispatched: 0, failed: 0,
37
+ }
38
+ const recent = []
39
+ const seenEvents = new Set()
40
+
41
+ const logInfo = (...args) => { try { logger?.info?.(...args) } catch { /* ignore */ } }
42
+ const logWarn = (...args) => { try { logger?.warn?.(...args) } catch { /* ignore */ } }
43
+
44
+ const pushRecent = (entry) => {
45
+ recent.unshift(Object.freeze({ at: Date.now(), ...entry }))
46
+ if (recent.length > RECENT_LIMIT) recent.length = RECENT_LIMIT
47
+ }
48
+ const rememberEvent = (eventId) => {
49
+ if (!eventId) return false
50
+ if (seenEvents.has(eventId)) return true
51
+ seenEvents.add(eventId)
52
+ if (seenEvents.size > DEDUPE_LIMIT) {
53
+ const first = seenEvents.values().next().value
54
+ seenEvents.delete(first)
55
+ }
56
+ return false
57
+ }
58
+
59
+ const router = {
60
+ trustMode: () => trustMode,
61
+ policy: () => describePolicy(policy),
62
+ /** 管理员/测试切换信任模式;不持久化(3.2.x 再接 configEditor)。 */
63
+ setTrustMode(value) {
64
+ const normalized = normalizePolicy({ ...basePolicy, trustMode: value }).trustMode
65
+ trustMode = normalized
66
+ policy = Object.freeze({ ...basePolicy, trustMode })
67
+ logInfo(`[superagent] trust mode -> ${trustMode}`)
68
+ return trustMode
69
+ },
70
+ /** 摄取一条已归一化事件(Webhook 适配器或测试调用)。 */
71
+ async ingest(event, { defer = false } = {}) {
72
+ counters.received += 1
73
+ if (rememberEvent(String(event?.eventId ?? ''))) {
74
+ counters.duplicate += 1
75
+ return { ok: true, decision: 'ignore', reason: 'duplicate-event', duplicate: true }
76
+ }
77
+ const dispatchAvailable = dispatcher.available() === true
78
+ const evaluated = evaluateEvent({ event, policy, dispatchAvailable })
79
+ if (evaluated.decision === 'ignore') {
80
+ counters.ignored += 1
81
+ const out = { ok: true, decision: 'ignore', reasons: evaluated.reasons, reason: evaluated.reason }
82
+ pushRecent({ ...out, eventId: String(event?.eventId ?? '') })
83
+ return out
84
+ }
85
+ const ticket = evaluated.ticket
86
+ if (evaluated.decision === 'observe') {
87
+ store.add(ticket, 'observed')
88
+ counters.observed += 1
89
+ const out = { ok: true, decision: 'observe', ticket: publicTicket(ticket, { status: 'observed' }) }
90
+ pushRecent({ decision: 'observe', ticketId: ticket.id, eventId: String(event?.eventId ?? '') })
91
+ return out
92
+ }
93
+ if (evaluated.decision === 'unavailable') {
94
+ store.add(ticket, 'unavailable', { reason: evaluated.reason })
95
+ counters.unavailable += 1
96
+ const out = {
97
+ ok: true, decision: 'unavailable', reason: evaluated.reason,
98
+ ticket: publicTicket(ticket, { status: 'unavailable' }),
99
+ dispatch: dispatcher.describe?.() ?? null,
100
+ }
101
+ pushRecent({ decision: 'unavailable', ticketId: ticket.id, reason: evaluated.reason })
102
+ return out
103
+ }
104
+ // accept → 配额检查 → 落库 pending → 派发;派发失败必须落 failed 而不是假装成功。
105
+ if (typeof beforeDispatch === 'function') {
106
+ let gate = null
107
+ try { gate = await beforeDispatch(ticket) } catch (error) { gate = { ok: false, reason: `quota-check-threw:${String(error?.message ?? error)}` } }
108
+ if (gate && gate.ok === false) {
109
+ const reason = String(gate.reason ?? 'dispatch-rejected')
110
+ store.add(ticket, 'unavailable', { reason })
111
+ counters.unavailable += 1
112
+ const out = {
113
+ ok: false, decision: 'unavailable', reason,
114
+ ticket: publicTicket(ticket, { status: 'unavailable' }),
115
+ quota: gate.quota ?? null,
116
+ }
117
+ pushRecent({ decision: 'unavailable', ticketId: ticket.id, reason })
118
+ return out
119
+ }
120
+ }
121
+ store.add(ticket, 'pending')
122
+ counters.accepted += 1
123
+ const run = async () => {
124
+ const dispatched = await dispatcher.dispatch(ticket)
125
+ const currentStatus = () => { try { return store.get(ticket.id)?.status ?? null } catch { return null } }
126
+ if (!dispatched || dispatched.ok !== true) {
127
+ if (!['done', 'failed', 'unavailable', 'observed', 'expired', 'abandoned'].includes(String(currentStatus()))) {
128
+ store.setStatus(ticket.id, 'failed', { code: dispatched?.code ?? 'dispatch-failed' })
129
+ }
130
+ counters.failed += 1
131
+ logWarn(`[superagent] dispatch failed for ${ticket.id}: ${dispatched?.code ?? 'unknown'}`)
132
+ const out = {
133
+ ok: false, decision: 'unavailable', reason: 'dispatch-failed',
134
+ code: dispatched?.code ?? 'dispatch-failed', error: dispatched?.error ?? 'dispatch failed',
135
+ ticket: publicTicket(ticket, { status: 'failed' }),
136
+ }
137
+ pushRecent({ decision: 'unavailable', ticketId: ticket.id, reason: out.code })
138
+ return out
139
+ }
140
+ const fork = dispatched.fork
141
+ // Worker 可能先于 dispatch() 返回完成(SSE/done 先到);终态不能被 dispatched 覆盖。
142
+ if (['pending', 'dispatched', 'running'].includes(String(currentStatus()))) {
143
+ store.setStatus(ticket.id, 'dispatched', { fork })
144
+ }
145
+ counters.dispatched += 1
146
+ const out = {
147
+ ok: true, decision: 'accept',
148
+ ticket: publicTicket(ticket, { status: 'dispatched' }),
149
+ fork,
150
+ forkId: fork?.forkId ?? null,
151
+ }
152
+ pushRecent({ decision: 'accept', ticketId: ticket.id, forkId: out.forkId })
153
+ return out
154
+ }
155
+ if (defer === true) {
156
+ // v3.2.7:Webhook 立即返回 202,派发在后台执行(fork 预置 10–30s,GitHub Webhook 超时约 10s)。
157
+ void run().catch((error) => {
158
+ try { store.setStatus(ticket.id, 'failed', { code: 'dispatch-async-failed', error: String(error?.message ?? error) }) } catch { /* ignore */ }
159
+ logWarn(`[superagent] async dispatch failed for ${ticket.id}: ${String(error?.message ?? error)}`)
160
+ })
161
+ pushRecent({ decision: 'accept', ticketId: ticket.id, async: true })
162
+ return { ok: true, decision: 'accept', accepted: true, pending: true, ticket: publicTicket(ticket, { status: 'pending' }) }
163
+ }
164
+ return await run()
165
+ },
166
+ /** GitHub Webhook:验签 + 归一化 + ingest;HTTP 语义与 handleGithubWebhook 对齐。 */
167
+ async ingestGithubWebhook({ rawBody, headers, secret, mentionHandles, defer = true } = {}) {
168
+ const handled = handleGithubWebhook({ rawBody, headers, secret, mentionHandles })
169
+ if (handled.status !== 200 || !handled.body?.event) return handled
170
+ // v3.2.7:默认后台派发——Webhook 必须在 GitHub 的超时(~10s)内返回,而 fork 预置需 10–30s。
171
+ const result = await router.ingest(handled.body.event, { defer })
172
+ return { status: 202, body: result }
173
+ },
174
+ /** 飞书 Webhook:token/challenge + 归一化 + ingest。 */
175
+ async ingestFeishuWebhook({ payload, verificationToken, mentionHandles, encryptKey } = {}) {
176
+ const handled = handleFeishuWebhook({ payload, verificationToken, mentionHandles, encryptKey })
177
+ if (handled.status !== 200 || !handled.body?.event) return handled
178
+ const result = await router.ingest(handled.body.event, { defer: true })
179
+ return { status: 202, body: result }
180
+ },
181
+ status() {
182
+ return {
183
+ ok: true,
184
+ trustMode,
185
+ policy: describePolicy(policy),
186
+ dispatch: dispatcher.describe?.() ?? null,
187
+ persistence: store.persistence?.() ?? null,
188
+ counters: { ...counters },
189
+ tickets: store.counts(),
190
+ activeTickets: store.size(),
191
+ }
192
+ },
193
+ tickets(limit = 20) {
194
+ return { ok: true, items: store.list({ limit }).map((record) => ({ ...publicTicket(record.ticket), status: record.status, updatedAt: record.updatedAt })) }
195
+ },
196
+ recent(limit = 10) { return recent.slice(0, Math.max(1, Math.floor(Number(limit)) || 10)) },
197
+ }
198
+ return router
199
+ }
200
+
201
+ export { normalizePolicy, evaluateEvent }