dsh-hams 0.1.3 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/README.md +4 -0
  3. package/dist/index.js +11335 -0
  4. package/package.json +9 -5
  5. package/src/domain/check.ts +0 -193
  6. package/src/domain/errors.ts +0 -59
  7. package/src/domain/evidence.ts +0 -134
  8. package/src/domain/ids.ts +0 -31
  9. package/src/host/chat-runtime.ts +0 -489
  10. package/src/host/dsh-adapter.ts +0 -867
  11. package/src/host/session-adapter.ts +0 -276
  12. package/src/http/handler.ts +0 -1132
  13. package/src/index.js +0 -53
  14. package/src/service/audit.ts +0 -92
  15. package/src/service/chat.ts +0 -1100
  16. package/src/service/contracts.ts +0 -193
  17. package/src/service/defaults.ts +0 -182
  18. package/src/service/deliverables.ts +0 -240
  19. package/src/service/hams.ts +0 -3103
  20. package/src/service/knowledge.ts +0 -940
  21. package/src/service/member-config.ts +0 -39
  22. package/src/service/members.ts +0 -506
  23. package/src/service/memory.ts +0 -375
  24. package/src/service/permissions.ts +0 -118
  25. package/src/service/project-lifecycle.ts +0 -233
  26. package/src/service/projects.ts +0 -840
  27. package/src/service/receipts.ts +0 -63
  28. package/src/service/templates.ts +0 -140
  29. package/src/service/tick.ts +0 -751
  30. package/src/service/tools.ts +0 -711
  31. package/src/service/types.ts +0 -219
  32. package/src/service/work-item-create.ts +0 -159
  33. package/src/service/workspace-profile.ts +0 -443
  34. package/src/store/backup.ts +0 -239
  35. package/src/store/bootstrap.ts +0 -225
  36. package/src/store/db.ts +0 -179
  37. package/src/store/lock.ts +0 -194
  38. package/src/store/migrations.ts +0 -211
  39. package/src/store/schema/001_p1.sql +0 -846
  40. package/src/store/schema/001_p1.sql.ts +0 -865
  41. package/src/store/schema/002_knowledge.sql +0 -54
  42. package/src/store/schema/002_knowledge.sql.ts +0 -68
  43. package/src/store/schema/003_projects.sql.ts +0 -3
  44. package/src/store/schema/004_team.sql.ts +0 -50
  45. package/src/store/schema/005_target_check.sql.ts +0 -22
  46. package/src/store/schema/006_target_flow.sql.ts +0 -19
  47. package/src/store/schema/007_project_delete.sql.ts +0 -17
  48. package/src/store/schema/008_memory.sql.ts +0 -47
  49. package/src/store/schema/generate-projects.mjs +0 -16
  50. package/src/store/seed.ts +0 -114
@@ -1,193 +0,0 @@
1
- /**
2
- * HTTP 契约:统一信封、统一回执、错误码 → 界面文案。
3
- *
4
- * 形态只有一种(backend-design §9):
5
- * POST /hams/api/v1/<domain>/<command>
6
- * 字面精确匹配;进站顺序固定为「验来源与主机 → 解析主体 → 校验岗位 → 解析负载 →
7
- * 查幂等 → 领域命令事务 → 审计与游标 → 回执」。
8
- *
9
- * 这一层只做协议与映射,不碰 `ctx.webServer`——宿主注册要等 C2 实测(能不能读
10
- * Origin / 自定义头)之后再接,避免把没验证过的假设写进核心路径。
11
- */
12
- import type { HamsErrorCode } from '../domain/errors.ts'
13
- import { HamsError } from '../domain/errors.ts'
14
- import type { CommandContext, CommandReceipt } from './types.ts'
15
-
16
- export interface CommandEnvelope {
17
- commandId: string
18
- idempotencyKey?: string
19
- objectId?: string
20
- expectedRevision?: number
21
- expectedEpoch?: number
22
- payload?: unknown
23
- }
24
-
25
- export interface QueryResult<T = unknown> {
26
- ok: true
27
- data: T
28
- correlationId: string
29
- }
30
-
31
- export interface ErrorResult {
32
- ok: false
33
- correlationId: string
34
- error: {
35
- code: HamsErrorCode
36
- message: string
37
- nextStep: string
38
- detail?: unknown
39
- }
40
- }
41
-
42
- export type HamsResponseBody = CommandReceipt | QueryResult | ErrorResult
43
-
44
- /**
45
- * 每个错误码对应的「下一步该做什么」——界面不许自己拼字符串,也不许只说「失败」。
46
- * 与 backend §9 的表格一一对应;用例会断言每个码都有文案。
47
- */
48
- export const NEXT_STEP: Record<HamsErrorCode, string> = {
49
- VERSION_CONFLICT: '刷新后在最新版本上重做;你的输入不会丢',
50
- EVIDENCE_CHANGED: '材料已变,请按新材料重新申请',
51
- APPROVAL_EXPIRED: '这张审批已过期,请重新申请',
52
- NOT_APPROVER: '只有申请时指定的审批人可以处理',
53
- POLICY_DENIED: '确认岗位是否在岗、是否具备该能力;需要时找管理员授权',
54
- STATE_INVALID: '刷新后重试;若状态已变,按当前状态里的可用动作操作',
55
- EPOCH_STALE: '这次操作已因生命周期变化作废,请刷新后重来',
56
- APPROVAL_REQUIRED: '先去申请关单,等有效审批通过',
57
- EVIDENCE_INCOMPLETE: '补齐缺的证据后重新申请',
58
- LEASE_LOST: '租约已被回收,运行结果记为未知,请核对后再重试',
59
- IDEMPOTENCY_MISMATCH: '同一幂等键不能改载荷;请换一个幂等键重试',
60
- CURSOR_EXPIRED: '游标已超出保留期,请重新拉取快照',
61
- DATA_STALE: '数据源已过期,结论只能记「无法判断」',
62
- EXTERNAL_RESULT_UNKNOWN: '外部动作结果不明,请去对账,不要直接重发',
63
- HOST_CAPABILITY_MISSING: '宿主能力缺失,已按降级方式处理;详情见诊断页',
64
- }
65
-
66
- export function nextStepFor(code: HamsErrorCode): string {
67
- return NEXT_STEP[code] ?? '联系管理员'
68
- }
69
-
70
- export interface ReceiptError {
71
- code: HamsErrorCode
72
- message: string
73
- nextStep: string
74
- detail?: unknown
75
- }
76
-
77
- /** 领域错误 → 回执错误块;非 HamsError 一律按 STATE_INVALID 处理,绝不把堆栈抛给界面。 */
78
- export function toReceiptError(error: unknown): ReceiptError {
79
- if (error instanceof HamsError) {
80
- return {
81
- code: error.code,
82
- message: error.message,
83
- nextStep: error.nextStep ?? nextStepFor(error.code),
84
- detail: error.detail,
85
- }
86
- }
87
- return {
88
- code: 'STATE_INVALID',
89
- message: error instanceof Error ? error.message : String(error),
90
- nextStep: nextStepFor('STATE_INVALID'),
91
- }
92
- }
93
-
94
- export function rejectedReceipt(correlationId: string, error: unknown): CommandReceipt {
95
- return {
96
- ok: false,
97
- status: 'rejected',
98
- auditId: '',
99
- correlationId,
100
- error: toReceiptError(error),
101
- }
102
- }
103
-
104
- export function errorResult(correlationId: string, error: unknown): ErrorResult {
105
- const mapped = toReceiptError(error)
106
- return { ok: false, correlationId, error: mapped }
107
- }
108
-
109
- // ───────────────────────── 负载校验 ─────────────────────────
110
- //
111
- // 这一版不引入 zod:需要的形状很少,手写校验更直白;校验失败统一走 STATE_INVALID,
112
- // 界面看到的是「这个请求不完整」,而不是 500。
113
-
114
- export class PayloadError extends HamsError {
115
- constructor(message: string) {
116
- super('STATE_INVALID', message, { nextStep: '检查请求参数后重试' })
117
- this.name = 'PayloadError'
118
- }
119
- }
120
-
121
- export function asObject(value: unknown, field: string): Record<string, unknown> {
122
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
123
- throw new PayloadError(`${field} 必须是对象`)
124
- }
125
- return value as Record<string, unknown>
126
- }
127
-
128
- export function asString(value: unknown, field: string, options: { allowEmpty?: boolean } = {}): string {
129
- if (typeof value !== 'string') throw new PayloadError(`${field} 必须是字符串`)
130
- if (!options.allowEmpty && value.trim().length === 0) throw new PayloadError(`${field} 不能为空`)
131
- return value
132
- }
133
-
134
- export function optionalString(value: unknown, field: string): string | undefined {
135
- if (value === undefined || value === null) return undefined
136
- return asString(value, field, { allowEmpty: true })
137
- }
138
-
139
- export function asNumber(value: unknown, field: string): number {
140
- const parsed = typeof value === 'number' ? value : Number(value)
141
- if (!Number.isFinite(parsed)) throw new PayloadError(`${field} 必须是数字`)
142
- return parsed
143
- }
144
-
145
- export function optionalNumber(value: unknown, field: string): number | undefined {
146
- if (value === undefined || value === null) return undefined
147
- return asNumber(value, field)
148
- }
149
-
150
- export function asArray<T = unknown>(value: unknown, field: string): T[] {
151
- if (!Array.isArray(value)) throw new PayloadError(`${field} 必须是数组`)
152
- return value as T[]
153
- }
154
-
155
- export function requireEnvelope(raw: unknown, options: { write: boolean; correlationId: string }): CommandEnvelope {
156
- const body = asObject(raw, '请求体')
157
- const commandId = asString(body.commandId, 'commandId')
158
- const idempotencyKey = optionalString(body.idempotencyKey, 'idempotencyKey')
159
- if (options.write && !idempotencyKey) {
160
- // 写命令必须能重放:没有幂等键就无法保证「同一个请求只生效一次」。
161
- throw new PayloadError(`写命令必须带 idempotencyKey(command ${commandId})`)
162
- }
163
- const envelope: CommandEnvelope = {
164
- commandId,
165
- idempotencyKey,
166
- objectId: optionalString(body.objectId, 'objectId'),
167
- expectedRevision: optionalNumber(body.expectedRevision, 'expectedRevision'),
168
- expectedEpoch: optionalNumber(body.expectedEpoch, 'expectedEpoch'),
169
- payload: body.payload,
170
- }
171
- return envelope
172
- }
173
-
174
- export function toCommandContext(input: {
175
- principalId: string
176
- channel: CommandContext['channel']
177
- correlationId: string
178
- envelope: CommandEnvelope
179
- authSubject?: string
180
- hostPreset?: string
181
- onBehalfOf?: string
182
- }): CommandContext {
183
- return {
184
- principalId: input.principalId,
185
- channel: input.channel,
186
- correlationId: input.correlationId,
187
- idempotencyKey: input.envelope.idempotencyKey,
188
- expectedRevision: input.envelope.expectedRevision,
189
- authSubject: input.authSubject,
190
- hostPreset: input.hostPreset,
191
- onBehalfOf: input.onBehalfOf,
192
- }
193
- }
@@ -1,182 +0,0 @@
1
- /**
2
- * 默认 bot:项目管理 / 数据分析。
3
- *
4
- * 两个 bot 是「开箱就能用」的底座,不是样例数据:
5
- * - 项目管理(`bot-pm`)是新项目默认的推进 bot:拆目标、派活、汇总;它没有审批/关单能力;
6
- * - 数据分析(`bot-data`)默认进入每个项目的群聊:查数、对比、给结论。
7
- *
8
- * 三条纪律:
9
- * 1. 幂等:按固定 id + 名字双查,已存在就不动(用户改过名字/配置也不覆盖);
10
- * 2. 只补缺失:默认 bot 被退休/暂停后不自动复活,避免覆盖人的决定;
11
- * 3. 责任人必须是本机操作者(人)——bot 不能没有责任人,也不能归 bot。
12
- *
13
- * 这两个 bot 由宿主启动时调用 `ensureDefaultBots` 落库;测试里直接调用它。
14
- */
15
- import type { DatabaseSync } from 'node:sqlite'
16
- import { createHash } from 'node:crypto'
17
- import type { IdFactory } from '../domain/ids.ts'
18
- import { stateInvalid } from '../domain/errors.ts'
19
-
20
- export const DEFAULT_COORDINATOR_NAME = '项目管理'
21
- export const DEFAULT_ANALYST_NAME = '数据分析'
22
- export const DEFAULT_COORDINATOR_ID = 'bot-pm'
23
- export const DEFAULT_ANALYST_ID = 'bot-data'
24
-
25
- export interface DefaultBotSpec {
26
- id: string
27
- name: string
28
- domain: string
29
- role: 'coordinator' | 'executor'
30
- responsibility: string
31
- capabilities: string[]
32
- }
33
-
34
- export const DEFAULT_BOTS: readonly DefaultBotSpec[] = Object.freeze([
35
- {
36
- id: DEFAULT_COORDINATOR_ID,
37
- name: DEFAULT_COORDINATOR_NAME,
38
- domain: '通用',
39
- role: 'coordinator',
40
- responsibility:
41
- '新项目默认推进 bot:把目标拆成工作项、在项目群里 @ 具体成员派活、汇总进展;改目标口径、审批与关单仍由人做。',
42
- capabilities: [
43
- 'work_item.claim',
44
- 'work_item.execute',
45
- 'work_item.request_closure',
46
- 'evidence.append',
47
- 'check.evaluate',
48
- 'proposal.create',
49
- ],
50
- },
51
- {
52
- id: DEFAULT_ANALYST_ID,
53
- name: DEFAULT_ANALYST_NAME,
54
- domain: '通用',
55
- role: 'executor',
56
- responsibility:
57
- '默认数据分析 bot:查指标与工作项数据、做对比、给结论并登记证据;不下市场判断、不批准关单。',
58
- capabilities: [
59
- 'work_item.claim',
60
- 'work_item.execute',
61
- 'evidence.append',
62
- 'metric.record',
63
- 'connector.ingest',
64
- ],
65
- },
66
- ])
67
-
68
- export interface DefaultBotResult {
69
- created: { key: string; id: string; name: string }[]
70
- existing: { key: string; id: string; name: string }[]
71
- }
72
-
73
- interface BotRow {
74
- id: string
75
- name: string
76
- kind: string
77
- status: string
78
- }
79
-
80
- function findBot(db: DatabaseSync, spec: DefaultBotSpec): BotRow | undefined {
81
- // 固定 id 优先;同名只认全局成员(scope_project_id 为空),别把项目内临时 bot 当默认。
82
- return db
83
- .prepare(
84
- `SELECT id, name, kind, status FROM member
85
- WHERE id = ? OR (name = ? AND scope_project_id IS NULL)
86
- ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END
87
- LIMIT 1`,
88
- )
89
- .get(spec.id, spec.name, spec.id) as BotRow | undefined
90
- }
91
-
92
- /** 按成员 id 或默认名字找一个在岗的默认 bot。 */
93
- export function findDefaultBot(db: DatabaseSync, key: 'coordinator' | 'analyst'): BotRow | null {
94
- const spec = key === 'coordinator' ? DEFAULT_BOTS[0] : DEFAULT_BOTS[1]
95
- const row = findBot(db, spec)
96
- return row && row.kind === 'bot' && row.status === 'active' ? row : null
97
- }
98
-
99
- /** 新项目没有显式指定推进 bot 时用它。找不到返回 null,不阻断建项目。 */
100
- export function defaultCoordinatorId(db: DatabaseSync): string | null {
101
- return findDefaultBot(db, 'coordinator')?.id ?? null
102
- }
103
-
104
- /** 幂等创建两个默认 bot;已存在(同名或同 id)就跳过。 */
105
- export function ensureDefaultBots(db: DatabaseSync, ids: IdFactory, now: number, ownerHumanId: string): DefaultBotResult {
106
- const owner = db.prepare('SELECT id, kind, status FROM member WHERE id = ?').get(ownerHumanId) as
107
- | { id: string; kind: string; status: string }
108
- | undefined
109
- if (!owner || owner.kind !== 'human' || owner.status !== 'active') {
110
- throw stateInvalid('默认 bot 的责任人必须是本机在岗的人')
111
- }
112
-
113
- const result: DefaultBotResult = { created: [], existing: [] }
114
- const insertMember = db.prepare(
115
- `INSERT INTO member (id, kind, name, domain, responsibility, role, status, current_version,
116
- owner_human_id, created_at, updated_at)
117
- VALUES (?, 'bot', ?, ?, ?, ?, 'active', 1, ?, ?, ?)`,
118
- )
119
- const insertVersion = db.prepare(
120
- `INSERT INTO member_version
121
- (id, member_id, version, agent_preset, model, memory_scope_json, prompt_digest,
122
- change_class, published_by, published_at)
123
- VALUES (?, ?, 1, 'hams-bot', NULL, '{}', ?, 'major', ?, ?)`,
124
- )
125
- const grant = db.prepare(
126
- 'INSERT OR IGNORE INTO member_permission (member_id, capability, scope, granted_by, granted_at) VALUES (?, ?, ?, ?, ?)',
127
- )
128
-
129
- for (const spec of DEFAULT_BOTS) {
130
- const existing = findBot(db, spec)
131
- if (existing) {
132
- result.existing.push({ key: spec.id, id: existing.id, name: existing.name })
133
- continue
134
- }
135
- const digest = createHash('sha256').update(`${spec.name}\n${spec.responsibility}`).digest('hex').slice(0, 16)
136
- insertMember.run(
137
- spec.id,
138
- spec.name,
139
- spec.domain,
140
- spec.responsibility,
141
- spec.role,
142
- ownerHumanId,
143
- now,
144
- now,
145
- )
146
- insertVersion.run(ids(), spec.id, digest, ownerHumanId, now)
147
- for (const capability of spec.capabilities) grant.run(spec.id, capability, 'global', ownerHumanId, now)
148
- result.created.push({ key: spec.id, id: spec.id, name: spec.name })
149
- }
150
- return result
151
- }
152
-
153
- /**
154
- * 新项目默认把数据分析 bot 拉进群:否则「@数据分析 去查一下」在开箱状态下无人可点。
155
- * 只补缺失,不复活被移出的人;推进 bot 自己不当群成员。
156
- */
157
- export function ensureDefaultAnalystInProject(
158
- db: DatabaseSync,
159
- projectId: string,
160
- coordinatorId: string | null,
161
- addedBy: string,
162
- now: number,
163
- ): { added: boolean; memberId: string | null } {
164
- const analyst = findDefaultBot(db, 'analyst')
165
- if (!analyst || analyst.id === coordinatorId) return { added: false, memberId: null }
166
- const project = db.prepare('SELECT id, status FROM project WHERE id = ? AND deleted_at IS NULL').get(projectId) as
167
- | { id: string; status: string }
168
- | undefined
169
- if (!project) return { added: false, memberId: null }
170
- const existing = db
171
- .prepare('SELECT removed_at FROM project_participant WHERE project_id = ? AND member_id = ?')
172
- .get(projectId, analyst.id) as { removed_at: number | null } | undefined
173
- if (existing && existing.removed_at === null) return { added: false, memberId: analyst.id }
174
- db.prepare(
175
- `INSERT INTO project_participant (project_id, member_id, kind, brief, added_by, added_at, removed_by, removed_at)
176
- VALUES (?, ?, 'member', ?, ?, ?, NULL, NULL)
177
- ON CONFLICT(project_id, member_id) DO UPDATE SET
178
- kind = 'member', brief = excluded.brief, added_by = excluded.added_by,
179
- added_at = excluded.added_at, removed_by = NULL, removed_at = NULL`,
180
- ).run(projectId, analyst.id, '默认数据分析:查指标与工作项数据,出结论。', addedBy, now)
181
- return { added: true, memberId: analyst.id }
182
- }
@@ -1,240 +0,0 @@
1
- /**
2
- * 项目交付物:把项目推进过程中 bot 工作区里生成的文件归集出来看。
3
- *
4
- * 两类来源(与会话适配器的工作目录约定一致):
5
- * - 工作项:`<dataDir>/bots/<memberId>/work/<workItemId>/…`(只认本项目的工作项);
6
- * - 项目群聊:`<dataDir>/bots/<memberId>/chats/chat-project-<projectId>/…` 与
7
- * `chats/chat-group-<projectId>-<memberId>/…`(推进 bot 与群成员在群里写下的东西)。
8
- * 只读扫描;预览只读文本且限制大小,绝不返回二进制内容。
9
- */
10
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
11
- import path from 'node:path'
12
- import type { DatabaseSync } from 'node:sqlite'
13
- import { HamsError } from '../domain/errors.ts'
14
-
15
- export interface DeliverableItem {
16
- /** 相对 dataDir 的路径,作为稳定 id,也用于预览时再次校验。 */
17
- relPath: string
18
- projectId: string
19
- workItemId: string
20
- workItemTitle: string
21
- workItemStatus: string
22
- memberId: string
23
- memberName: string
24
- /** work = 工作项产物;chat = 项目群聊产物。 */
25
- source: 'work' | 'chat'
26
- /** 工作项标题或「项目群聊」。 */
27
- label: string
28
- name: string
29
- size: number
30
- mtime: number
31
- }
32
-
33
- export interface DeliverableList {
34
- items: DeliverableItem[]
35
- truncated: boolean
36
- /** 扫描过的项目工作项数,便于界面解释「为什么是空的」。 */
37
- workItems: number
38
- }
39
-
40
- export interface DeliverableFile {
41
- relPath: string
42
- name: string
43
- size: number
44
- mtime: number
45
- binary: boolean
46
- truncated: boolean
47
- content: string
48
- }
49
-
50
- const MAX_FILES = 500
51
- const MAX_DEPTH = 6
52
- const MAX_PREVIEW_BYTES = 256 * 1024
53
- const SKIP_DIRS = new Set(['node_modules', '.git', '.cache', '.venv', '__pycache__'])
54
-
55
- interface WorkItemRef {
56
- id: string
57
- title: string
58
- status: string
59
- }
60
-
61
- function workItemsOfProject(db: DatabaseSync, projectId: string): WorkItemRef[] {
62
- return db
63
- .prepare('SELECT id, title, status FROM work_item WHERE project_id = ? ORDER BY updated_at DESC')
64
- .all(projectId) as WorkItemRef[]
65
- }
66
-
67
- function memberNames(db: DatabaseSync): Map<string, string> {
68
- const rows = db.prepare("SELECT id, name FROM member WHERE kind = 'bot'").all() as { id: string; name: string }[]
69
- return new Map(rows.map((row) => [row.id, row.name]))
70
- }
71
-
72
- function walk(root: string, relRoot: string, onFile: (rel: string, size: number, mtime: number) => void, budget: { left: number }, depth = 0): void {
73
- if (budget.left <= 0 || depth > MAX_DEPTH) return
74
- let entries: ReturnType<typeof readdirSync>
75
- try {
76
- entries = readdirSync(root, { withFileTypes: true })
77
- } catch {
78
- return
79
- }
80
- for (const entry of entries) {
81
- if (budget.left <= 0) return
82
- if (entry.isSymbolicLink()) continue
83
- const full = path.join(root, entry.name)
84
- if (entry.isDirectory()) {
85
- if (SKIP_DIRS.has(entry.name)) continue
86
- walk(full, `${relRoot}/${entry.name}`, onFile, budget, depth + 1)
87
- continue
88
- }
89
- if (!entry.isFile()) continue
90
- let stat
91
- try {
92
- stat = statSync(full)
93
- } catch {
94
- continue
95
- }
96
- budget.left -= 1
97
- onFile(`${relRoot}/${entry.name}`, stat.size, stat.mtimeMs)
98
- }
99
- }
100
-
101
- export function listDeliverables(options: { db: DatabaseSync; dataDir: string; projectId: string; limit?: number }): DeliverableList {
102
- const dataDir = path.resolve(options.dataDir)
103
- const botsRoot = path.join(dataDir, 'bots')
104
- const workItems = workItemsOfProject(options.db, options.projectId)
105
- if (!existsSync(botsRoot)) return { items: [], truncated: false, workItems: workItems.length }
106
- const names = memberNames(options.db)
107
- const budget = { left: Math.min(Math.max(options.limit ?? MAX_FILES, 1), MAX_FILES) }
108
- const items: DeliverableItem[] = []
109
- let truncated = false
110
- for (const memberId of readdirSync(botsRoot)) {
111
- const memberDir = path.join(botsRoot, memberId)
112
- let isDir = false
113
- try {
114
- isDir = statSync(memberDir).isDirectory()
115
- } catch {
116
- isDir = false
117
- }
118
- if (!isDir) continue
119
- for (const item of workItems) {
120
- const workDir = path.join(memberDir, 'work', item.id)
121
- if (!existsSync(workDir)) continue
122
- const relBase = `bots/${memberId}/work/${item.id}`
123
- walk(workDir, relBase, (relPath, size, mtime) => {
124
- items.push({
125
- relPath,
126
- projectId: options.projectId,
127
- workItemId: item.id,
128
- workItemTitle: item.title,
129
- workItemStatus: item.status,
130
- memberId,
131
- memberName: names.get(memberId) || memberId,
132
- source: 'work',
133
- label: item.title,
134
- name: path.posix.basename(relPath),
135
- size,
136
- mtime: Math.round(mtime),
137
- })
138
- }, budget)
139
- if (budget.left <= 0) {
140
- truncated = true
141
- break
142
- }
143
- }
144
- if (truncated) break
145
- // 项目群聊产物:chats/chat-project-<pid>/ 与 chats/chat-group-<pid>-<memberId>/
146
- const chatsDir = path.join(memberDir, 'chats')
147
- if (!existsSync(chatsDir)) continue
148
- for (const chatEntry of readdirSync(chatsDir)) {
149
- const isProjectChat = chatEntry === `chat-project-${options.projectId}`
150
- || chatEntry.startsWith(`chat-group-${options.projectId}-`)
151
- if (!isProjectChat) continue
152
- const chatDir = path.join(chatsDir, chatEntry)
153
- let isDir = false
154
- try {
155
- isDir = statSync(chatDir).isDirectory()
156
- } catch {
157
- isDir = false
158
- }
159
- if (!isDir) continue
160
- const relBase = `bots/${memberId}/chats/${chatEntry}`
161
- walk(chatDir, relBase, (relPath, size, mtime) => {
162
- items.push({
163
- relPath,
164
- projectId: options.projectId,
165
- workItemId: '',
166
- workItemTitle: '',
167
- workItemStatus: '',
168
- memberId,
169
- memberName: names.get(memberId) || memberId,
170
- source: 'chat',
171
- label: '项目群聊',
172
- name: path.posix.basename(relPath),
173
- size,
174
- mtime: Math.round(mtime),
175
- })
176
- }, budget)
177
- if (budget.left <= 0) {
178
- truncated = true
179
- break
180
- }
181
- }
182
- if (truncated) break
183
- }
184
- items.sort((a, b) => b.mtime - a.mtime)
185
- return { items, truncated, workItems: workItems.length }
186
- }
187
-
188
- export function readDeliverable(options: {
189
- db: DatabaseSync
190
- dataDir: string
191
- projectId: string
192
- relPath: string
193
- }): DeliverableFile {
194
- const dataDir = path.resolve(options.dataDir)
195
- const botsRoot = path.join(dataDir, 'bots')
196
- const raw = String(options.relPath || '').trim()
197
- if (!raw) throw new HamsError('STATE_INVALID', '缺少文件路径')
198
- if (path.isAbsolute(raw)) throw new HamsError('POLICY_DENIED', '只接受项目工作区内的相对路径')
199
- const resolved = path.resolve(dataDir, raw)
200
- const base = botsRoot + path.sep
201
- if (!resolved.startsWith(base)) throw new HamsError('POLICY_DENIED', '只允许看 bot 工作区里的文件')
202
- const parts = path.relative(dataDir, resolved).split(path.sep)
203
- // bots/<memberId>/work/<workItemId>/... 或 bots/<memberId>/chats/<chatKey>/...
204
- if (parts.length < 5 || parts[0] !== 'bots') {
205
- throw new HamsError('POLICY_DENIED', '路径不在交付物目录约定内')
206
- }
207
- if (parts[2] === 'work') {
208
- const workItemId = parts[3]
209
- const owned = options.db
210
- .prepare('SELECT 1 AS ok FROM work_item WHERE id = ? AND project_id = ?')
211
- .get(workItemId, options.projectId) as { ok: number } | undefined
212
- if (!owned) throw new HamsError('POLICY_DENIED', '这个文件不属于当前项目的工作项')
213
- } else if (parts[2] === 'chats') {
214
- const chatKey = parts[3]
215
- const isProjectChat = chatKey === `chat-project-${options.projectId}`
216
- || chatKey.startsWith(`chat-group-${options.projectId}-`)
217
- if (!isProjectChat) throw new HamsError('POLICY_DENIED', '这个文件不属于当前项目的群聊工作区')
218
- } else {
219
- throw new HamsError('POLICY_DENIED', '路径不在交付物目录约定内')
220
- }
221
- let stat
222
- try {
223
- stat = statSync(resolved)
224
- } catch {
225
- throw new HamsError('STATE_INVALID', '文件不存在或已移走')
226
- }
227
- if (!stat.isFile()) throw new HamsError('STATE_INVALID', '不是普通文件')
228
- const readBytes = Math.min(stat.size, MAX_PREVIEW_BYTES)
229
- const buffer = readFileSync(resolved).subarray(0, readBytes)
230
- const binary = buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0)
231
- return {
232
- relPath: path.relative(dataDir, resolved).split(path.sep).join('/'),
233
- name: path.posix.basename(resolved),
234
- size: stat.size,
235
- mtime: Math.round(stat.mtimeMs),
236
- binary,
237
- truncated: stat.size > readBytes,
238
- content: binary ? '' : buffer.toString('utf8'),
239
- }
240
- }