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/service/contracts.ts
DELETED
|
@@ -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
|
-
}
|
package/src/service/defaults.ts
DELETED
|
@@ -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
|
-
}
|