dsh-bot-team 1.1.0 → 1.3.0

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/host/domain.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Durable repository for Bot Team, backed by the Host storage-domain service.
3
3
  *
4
- * Layout: one domain `bot_team` (per-record) with the tables bots / groups /
5
- * folders / members / messages / meta. Records are plain JSON; the domain calls each
4
+ * Layout: one domain `bot_team` (per-record), mapped by storage-layout.js to the
5
+ * physical `dsh-bot-team` directory, with the tables bots / groups /
6
+ * folders / members / messages / conversations / session_bindings / meta.
7
+ * Records are plain JSON; the domain calls each
6
8
  * table's `parse` when loading stored records (see `recordSchema`). Memory is
7
9
  * authoritative for reads; every write awaits backend durability through the
8
10
  * domain's per-domain write chain.
@@ -10,21 +12,29 @@
10
12
  * Keys are path-safe (`[a-zA-Z0-9_-]` only) because the JSON backend maps one
11
13
  * record key to one file (M0 finding P3).
12
14
  */
15
+ import { createHash, randomUUID } from 'node:crypto';
13
16
  import { now, recordSchema } from './util.js';
14
17
  import { directGroupIdFor, directMembershipIdFor, directSessionIdFor } from './layout.js';
18
+ import { isDiscussionActive } from './discussion-flow.js';
15
19
 
16
20
  export const DOMAIN_NAME = 'bot_team';
17
21
  export const DOMAIN_VERSION = 1;
18
22
  /** Record-shape schema version stored in the meta table (domain version stays 1 by design). */
19
- export const SCHEMA_VERSION = 6;
23
+ export const SCHEMA_VERSION = 8;
24
+
25
+ const digest = value => createHash('sha256').update(value).digest('hex');
26
+ const baselineId = groupId => `conv_${digest(`baseline:${groupId}`)}`;
27
+ const bindingIdFor = sessionId => `binding_${digest(sessionId)}`;
28
+ const copy = value => structuredClone(value);
29
+ const failure = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
20
30
 
21
31
  function sortBySeq(list) {
22
32
  list.sort((a, b) => a.seq - b.seq);
23
33
  return list;
24
34
  }
25
35
 
26
- export async function openRepository(ctx, logger) {
27
- const domain = await ctx.storageDomain.open({
36
+ export async function openRepository(ctx, logger, openDomain = spec => ctx.storageDomain.open(spec)) {
37
+ const domain = await openDomain({
28
38
  name: DOMAIN_NAME,
29
39
  version: DOMAIN_VERSION,
30
40
  layout: 'per-record',
@@ -36,6 +46,8 @@ export async function openRepository(ctx, logger) {
36
46
  messages: { valueSchema: recordSchema('messages', ['messageId', 'groupId', 'seq']) },
37
47
  discussions: { valueSchema: recordSchema('discussions', ['discussionId', 'groupId', 'topic']) },
38
48
  tasks: { valueSchema: recordSchema('tasks', ['taskId', 'groupId', 'title']) },
49
+ conversations: { valueSchema: recordSchema('conversations', ['conversationId', 'groupId', 'ordinal', 'previousConversationId', 'afterSeq', 'createdAt', 'reason', 'requestId']) },
50
+ session_bindings: { valueSchema: recordSchema('session_bindings', ['bindingId', 'sessionId', 'groupId', 'conversationId', 'membershipId', 'botId']) },
39
51
  meta: { valueSchema: recordSchema('meta', []) },
40
52
  },
41
53
  });
@@ -49,6 +61,179 @@ export async function openRepository(ctx, logger) {
49
61
  const tasks = new Map();
50
62
  const meta = new Map();
51
63
  const messagesByGroup = new Map();
64
+ const conversations = new Map();
65
+ const sessionBindings = new Map();
66
+ const bindingsBySession = new Map();
67
+ const groupWrites = new Map();
68
+ const requestWrites = new Map();
69
+ const failedWrites = new Map();
70
+ // Keep committed generation fields away from mutable objects held by callers.
71
+ const groupHeads = new Map();
72
+ const groupNextSeq = new Map();
73
+ const memberMappings = new Map();
74
+ let bindingWrites = Promise.resolve();
75
+ const deletedGroupIds = new Set();
76
+ const deletedMemberIds = new Set();
77
+
78
+ function enqueue(groupId, operation, recoveryKey) {
79
+ const pending = (groupWrites.get(groupId) ?? Promise.resolve()).then(operation);
80
+ const settled = pending.then(() => {
81
+ if (recoveryKey) failedWrites.get(groupId)?.delete(recoveryKey);
82
+ }, error => {
83
+ if (recoveryKey) {
84
+ if (!failedWrites.has(groupId)) failedWrites.set(groupId, new Map());
85
+ failedWrites.get(groupId).set(recoveryKey, error);
86
+ }
87
+ });
88
+ groupWrites.set(groupId, settled);
89
+ settled.then(() => { if (groupWrites.get(groupId) === settled) groupWrites.delete(groupId); });
90
+ return pending;
91
+ }
92
+
93
+ function serializeRequest(requestId, operation) {
94
+ const pending = (requestWrites.get(requestId) ?? Promise.resolve()).then(operation);
95
+ const settled = pending.catch(() => {});
96
+ requestWrites.set(requestId, settled);
97
+ settled.then(() => { if (requestWrites.get(requestId) === settled) requestWrites.delete(requestId); });
98
+ return pending;
99
+ }
100
+
101
+ function publishGroup(group) {
102
+ group.nextSeq = Math.max(groupNextSeq.get(group.groupId) ?? 0, group.nextSeq ?? 0);
103
+ groupNextSeq.set(group.groupId, group.nextSeq);
104
+ const head = { activeConversationId: group.activeConversationId, conversationVersion: group.conversationVersion };
105
+ groupHeads.set(group.groupId, head);
106
+ groups.set(group.groupId, group);
107
+ return group;
108
+ }
109
+
110
+ function publishMember(member) {
111
+ memberMappings.set(member.membershipId, copy(member));
112
+ members.set(member.membershipId, member);
113
+ return member;
114
+ }
115
+
116
+ function committedChain(groupId) {
117
+ const group = groups.get(groupId);
118
+ if (!group) return [];
119
+ const head = groupHeads.get(groupId) ?? group;
120
+ if (!head.activeConversationId) {
121
+ if (repo.schemaVersion() >= 7) throw failure('conversation-corrupt', '聊天缺少当前会话', { groupId });
122
+ return [];
123
+ }
124
+ const chain = [];
125
+ const visited = new Set();
126
+ let id = head.activeConversationId;
127
+ let ordinal = head.conversationVersion;
128
+ let upper = Number.isSafeInteger(group.nextSeq) ? group.nextSeq : 0;
129
+ while (id) {
130
+ const conversation = conversations.get(id);
131
+ if (visited.has(id) || !conversation || conversation.groupId !== groupId || conversation.conversationId !== id ||
132
+ !Number.isSafeInteger(ordinal) || ordinal < 1 || conversation.ordinal !== ordinal ||
133
+ !Number.isSafeInteger(conversation.afterSeq) || conversation.afterSeq < 0 || conversation.afterSeq > upper ||
134
+ (ordinal === 1 ? conversation.previousConversationId !== null || conversation.afterSeq !== 0 : typeof conversation.previousConversationId !== 'string' || !conversation.previousConversationId)) {
135
+ throw failure('conversation-corrupt', '聊天会话历史链损坏', { groupId, conversationId: id });
136
+ }
137
+ visited.add(id);
138
+ chain.push(conversation);
139
+ id = conversation.previousConversationId;
140
+ ordinal--;
141
+ upper = conversation.afterSeq;
142
+ }
143
+ if (ordinal !== 0) throw failure('conversation-corrupt', '聊天会话版本不一致', { groupId });
144
+ return chain.reverse();
145
+ }
146
+
147
+ async function writeConversation(conversation) {
148
+ const existing = conversations.get(conversation.conversationId);
149
+ if (existing) {
150
+ if (JSON.stringify(existing) !== JSON.stringify(conversation)) throw failure('conflict', '不可修改已存在的会话记录');
151
+ return existing;
152
+ }
153
+ const stored = Object.freeze(copy(conversation));
154
+ await domain.table('conversations').put(stored.conversationId, stored);
155
+ conversations.set(stored.conversationId, stored);
156
+ return stored;
157
+ }
158
+
159
+ function writeBinding(member, options) {
160
+ const pending = bindingWrites.then(() => writeBindingRecord(member, options));
161
+ bindingWrites = pending.catch(() => {});
162
+ return pending;
163
+ }
164
+
165
+ async function writeBindingRecord(member, { sessionId, conversationId, reason = 'session-created' }) {
166
+ if (typeof sessionId !== 'string' || !sessionId.trim() || typeof conversationId !== 'string') throw failure('invalid-input', 'Session 归属缺少标识');
167
+ const conversation = conversations.get(conversationId);
168
+ if (!conversation || conversation.groupId !== member.groupId) throw failure('conversation-corrupt', 'Session 归属引用了无效会话');
169
+ const existing = bindingsBySession.get(sessionId);
170
+ if (existing) {
171
+ if (existing.groupId !== member.groupId || existing.conversationId !== conversationId || existing.membershipId !== member.membershipId || existing.botId !== member.botId) {
172
+ throw failure('session-binding-conflict', '同一 Session 存在互相冲突的聊天或成员归属', { sessionId, groupId: member.groupId });
173
+ }
174
+ return existing;
175
+ }
176
+ const binding = Object.freeze({
177
+ bindingId: bindingIdFor(sessionId), sessionId, groupId: member.groupId, conversationId,
178
+ membershipId: member.membershipId, botId: member.botId,
179
+ botNameSnapshot: member.botNameSnapshot ?? bots.get(member.botId)?.name ?? member.botId,
180
+ createdAt: now(), reason,
181
+ });
182
+ await domain.table('session_bindings').put(binding.bindingId, binding);
183
+ sessionBindings.set(binding.bindingId, binding);
184
+ bindingsBySession.set(sessionId, binding);
185
+ return binding;
186
+ }
187
+
188
+ async function initializeConversation(group) {
189
+ const existing = groupHeads.get(group.groupId)?.activeConversationId;
190
+ if (existing) return committedChain(group.groupId).at(-1);
191
+ const conversationId = baselineId(group.groupId);
192
+ const conversation = await writeConversation(conversations.get(conversationId) ?? {
193
+ conversationId, groupId: group.groupId, ordinal: 1, previousConversationId: null,
194
+ afterSeq: 0, createdAt: group.createdAt ?? now(), reason: 'legacy', requestId: null,
195
+ });
196
+ if (conversation.groupId !== group.groupId || conversation.ordinal !== 1 || conversation.afterSeq !== 0 || conversation.previousConversationId !== null) throw failure('conversation-corrupt', '聊天基线会话损坏');
197
+ for (const current of [...members.values()].filter(member => member.groupId === group.groupId)) {
198
+ const member = copy(current);
199
+ if (member.sessionConversationId && member.sessionConversationId !== conversationId) throw failure('conversation-corrupt', '旧成员的 Session 会话不属于迁移基线');
200
+ for (const sessionId of new Set([member.sessionId, ...(member.previousSessionIds ?? [])].filter(Boolean))) {
201
+ await writeBinding(member, { sessionId, conversationId, reason: 'legacy-member' });
202
+ }
203
+ if (!member.sessionConversationId) {
204
+ member.sessionConversationId = conversationId;
205
+ await domain.table('members').put(member.membershipId, member);
206
+ publishMember(member);
207
+ }
208
+ }
209
+ // These references were recorded by the Host before handing input to an Agent.
210
+ // Preserve ownership even when that member has since been removed.
211
+ for (const message of messagesByGroup.get(group.groupId) ?? []) {
212
+ const execution = message.meta?.execution;
213
+ const author = message.author;
214
+ if (!execution?.sessionId) continue;
215
+ if (execution.version !== 1 || typeof execution.jobId !== 'string' || !execution.jobId ||
216
+ typeof execution.sessionId !== 'string' || author?.type !== 'bot' || !author.membershipId || !author.botId) {
217
+ warn('legacy session binding', new Error('无法可靠关联执行引用: ' + message.messageId));
218
+ continue;
219
+ }
220
+ await writeBinding({ groupId: group.groupId, membershipId: author.membershipId, botId: author.botId, botNameSnapshot: author.name },
221
+ { sessionId: execution.sessionId, conversationId, reason: 'legacy-execution' });
222
+ }
223
+ const stored = { ...copy(groups.get(group.groupId) ?? {}), ...copy(group), activeConversationId: conversationId, conversationVersion: 1 };
224
+ await domain.table('groups').put(group.groupId, stored);
225
+ publishGroup(stored);
226
+ return conversation;
227
+ }
228
+ // Strict deletions reserve the id before awaiting storage. Keep successful ids
229
+ // for this repository lifetime so a late callback cannot recreate a message.
230
+ const deletingMessageIds = new Set();
231
+ const deletedMessageIds = new Set();
232
+ const messageWriteError = (messageId) => {
233
+ if (deletingMessageIds.has(messageId)) return Object.assign(new Error('消息正在删除'), { code: 'busy' });
234
+ if (deletedMessageIds.has(messageId)) return Object.assign(new Error('消息已删除'), { code: 'not-found' });
235
+ return null;
236
+ };
52
237
 
53
238
  for (const [key, value] of domain.table('bots').entries()) bots.set(key, value);
54
239
  for (const [key, value] of domain.table('groups').entries()) {
@@ -59,14 +244,14 @@ export async function openRepository(ctx, logger) {
59
244
  if (group.folderId === undefined) group.folderId = null;
60
245
  // `order` intentionally stays undefined when missing: the v6 migration uses
61
246
  // that to assign the pre-upgrade display order exactly once.
62
- groups.set(key, group);
247
+ publishGroup(group);
63
248
  }
64
249
  for (const [key, value] of domain.table('folders').entries()) {
65
250
  const folder = { ...value };
66
251
  if (!Number.isInteger(folder.order)) folder.order = 0;
67
252
  folders.set(key, folder);
68
253
  }
69
- for (const [key, value] of domain.table('members').entries()) members.set(key, value);
254
+ for (const [key, value] of domain.table('members').entries()) publishMember(value);
70
255
  for (const [key, value] of domain.table('messages').entries()) {
71
256
  messages.set(key, value);
72
257
  const list = messagesByGroup.get(value.groupId) ?? [];
@@ -74,9 +259,29 @@ export async function openRepository(ctx, logger) {
74
259
  messagesByGroup.set(value.groupId, list);
75
260
  }
76
261
  for (const list of messagesByGroup.values()) sortBySeq(list);
77
- for (const [key, value] of domain.table('discussions').entries()) discussions.set(key, value);
262
+ for (const [key, value] of domain.table('discussions').entries()) {
263
+ discussions.set(key, {
264
+ ...copy(value),
265
+ protocolVersion: value.protocolVersion ?? 1,
266
+ mode: value.mode ?? 'discussion',
267
+ format: value.format ?? 'classic',
268
+ revision: Number.isSafeInteger(value.revision) ? value.revision : 0,
269
+ });
270
+ }
78
271
  for (const [key, value] of domain.table('tasks').entries()) tasks.set(key, value);
79
272
  for (const [key, value] of domain.table('meta').entries()) meta.set(key, value);
273
+ for (const [key, value] of domain.table('conversations').entries()) {
274
+ if (key !== value.conversationId) throw failure('conversation-corrupt', '会话存储键不一致');
275
+ conversations.set(key, Object.freeze(copy(value)));
276
+ }
277
+ for (const [key, value] of domain.table('session_bindings').entries()) {
278
+ if (typeof value.sessionId !== 'string' || key !== value.bindingId || key !== bindingIdFor(value.sessionId) || bindingsBySession.has(value.sessionId)) {
279
+ throw failure('conversation-corrupt', 'Session 归属存储键冲突');
280
+ }
281
+ const binding = Object.freeze(copy(value));
282
+ sessionBindings.set(key, binding);
283
+ bindingsBySession.set(binding.sessionId, binding);
284
+ }
80
285
 
81
286
  // Crash recovery: a previous process may have died mid-turn / mid-discussion.
82
287
  for (const message of messages.values()) {
@@ -84,20 +289,33 @@ export async function openRepository(ctx, logger) {
84
289
  message.status = 'error';
85
290
  message.error = 'Host 重启,该回复被中断,可重试';
86
291
  message.updatedAt = now();
87
- domain.table('messages').put(message.messageId, message).catch(() => {});
292
+ enqueue(message.groupId, () => domain.table('messages').put(message.messageId, copy(message)), 'message:' + message.messageId).catch(() => {});
88
293
  }
89
294
  if (message.kind === 'approval' && message.meta && message.meta.approvalState === 'pending') {
90
295
  message.meta = { ...message.meta, approvalState: 'cancelled', decidedAt: now() };
91
296
  message.updatedAt = now();
92
- domain.table('messages').put(message.messageId, message).catch(() => {});
297
+ enqueue(message.groupId, () => domain.table('messages').put(message.messageId, copy(message)), 'message:' + message.messageId).catch(() => {});
93
298
  }
94
299
  }
95
300
  for (const discussion of discussions.values()) {
96
- if (discussion.status === 'running') {
97
- discussion.status = 'stopped';
98
- discussion.stopReason = 'host-restart';
301
+ if (isDiscussionActive(discussion)) {
302
+ if (discussion.protocolVersion >= 2) {
303
+ // Preserve the precise step/attempt journal; only the engine may reconcile
304
+ // a native execution. Reloading the repository must never replay a turn.
305
+ if (discussion.status !== 'paused') {
306
+ discussion.recoveryStatus = discussion.status;
307
+ discussion.pauseReason = 'host-restart';
308
+ }
309
+ discussion.status = 'paused';
310
+ discussion.needsRecovery = true;
311
+ } else {
312
+ discussion.status = 'stopped';
313
+ discussion.stopReason = 'host-restart';
314
+ }
315
+ discussion.revision += 1;
99
316
  discussion.updatedAt = now();
100
- domain.table('discussions').put(discussion.discussionId, discussion).catch(() => {});
317
+ const snapshot = copy(discussion);
318
+ enqueue(discussion.groupId, () => domain.table('discussions').put(discussion.discussionId, snapshot), 'discussion:' + discussion.discussionId).catch(() => {});
101
319
  }
102
320
  }
103
321
 
@@ -113,7 +331,31 @@ export async function openRepository(ctx, logger) {
113
331
  for (const group of groups.values()) {
114
332
  const list = messagesByGroup.get(group.groupId);
115
333
  const maxSeq = list && list.length > 0 ? list[list.length - 1].seq : 0;
116
- if (!Number.isInteger(group.nextSeq) || group.nextSeq < maxSeq) group.nextSeq = maxSeq;
334
+ if (!Number.isSafeInteger(group.nextSeq) || group.nextSeq < maxSeq) group.nextSeq = maxSeq;
335
+ groupNextSeq.set(group.groupId, group.nextSeq);
336
+ }
337
+
338
+ function recordConversation(record, previous) {
339
+ const previousId = previous && repo.conversationFor(previous)?.conversationId;
340
+ if (previous && record.conversationId && previousId !== record.conversationId) throw failure('conflict', '不可更改历史记录的会话归属');
341
+ record.conversationId ??= previousId ?? repo.activeConversation(record.groupId)?.conversationId;
342
+ if (!repo.conversationFor(record)) throw failure('conversation-corrupt', '记录缺少已提交的会话归属');
343
+ return record;
344
+ }
345
+
346
+ function validateConversations() {
347
+ for (const group of groups.values()) committedChain(group.groupId);
348
+ for (const member of members.values()) {
349
+ if (!groups.has(member.groupId)) continue;
350
+ const binding = bindingsBySession.get(member.sessionId);
351
+ if (!member.sessionConversationId || !repo.conversationFor({ groupId: member.groupId, conversationId: member.sessionConversationId }) ||
352
+ !binding || binding.groupId !== member.groupId || binding.conversationId !== member.sessionConversationId || binding.membershipId !== member.membershipId || binding.botId !== member.botId) {
353
+ throw failure('conversation-corrupt', '成员 Session 归属不完整', { membershipId: member.membershipId });
354
+ }
355
+ }
356
+ for (const binding of sessionBindings.values()) {
357
+ if (groups.has(binding.groupId) && !repo.conversationFor(binding)) throw failure('conversation-corrupt', 'Session 归属引用了未提交会话');
358
+ }
117
359
  }
118
360
 
119
361
  const repo = {
@@ -124,18 +366,95 @@ export async function openRepository(ctx, logger) {
124
366
  members,
125
367
  messages,
126
368
  meta,
369
+ conversations,
370
+ sessionBindings,
371
+
372
+ activeConversation(groupId) {
373
+ return committedChain(groupId).at(-1);
374
+ },
375
+ conversationsOf(groupId) {
376
+ return committedChain(groupId);
377
+ },
378
+ conversationFor(record) {
379
+ if (!record?.groupId) return undefined;
380
+ const chain = committedChain(record.groupId);
381
+ return record.conversationId == null ? chain[0] : chain.find(item => item.conversationId === record.conversationId);
382
+ },
383
+ sessionBindingsOf(groupId, conversationId) {
384
+ const committed = new Set(committedChain(groupId).map(item => item.conversationId));
385
+ return [...sessionBindings.values()].filter(binding => binding.groupId === groupId && committed.has(binding.conversationId) &&
386
+ (conversationId === undefined || binding.conversationId === conversationId))
387
+ .sort((a, b) => a.createdAt - b.createdAt || a.bindingId.localeCompare(b.bindingId));
388
+ },
389
+ sessionBindingFor(sessionId) {
390
+ const binding = bindingsBySession.get(sessionId);
391
+ return binding && repo.conversationFor(binding) ? binding : undefined;
392
+ },
393
+ bindSession(member, options = {}) {
394
+ const candidate = copy(member);
395
+ const binding = copy(options);
396
+ return enqueue(candidate.groupId, () => {
397
+ const latest = memberMappings.get(candidate.membershipId);
398
+ if (!latest || latest.groupId !== candidate.groupId || latest.botId !== candidate.botId || deletedMemberIds.has(candidate.membershipId)) throw failure('not-found', '成员不存在');
399
+ if (!repo.conversationFor({ groupId: candidate.groupId, conversationId: binding.conversationId })) throw failure('conversation-corrupt', 'Session 必须属于已提交会话');
400
+ return writeBinding(candidate, binding);
401
+ });
402
+ },
403
+ ensureConversation(groupId) {
404
+ return enqueue(groupId, () => {
405
+ const group = groups.get(groupId);
406
+ if (!group || deletedGroupIds.has(groupId)) throw failure('not-found', '聊天不存在');
407
+ return initializeConversation(group);
408
+ });
409
+ },
410
+ async flushGroupWrites(groupId) {
411
+ while (groupWrites.has(groupId)) await groupWrites.get(groupId);
412
+ const errors = [...(failedWrites.get(groupId)?.values() ?? [])];
413
+ if (errors.length) throw failure('persistence-failed', '聊天仍有未成功持久化的更改', { cause: errors[0], groupId });
414
+ },
415
+ commitConversation(groupId, { expectedConversationId, requestId } = {}) {
416
+ if (typeof expectedConversationId !== 'string' || !expectedConversationId || typeof requestId !== 'string' || !requestId.trim() || requestId.length > 200) {
417
+ return Promise.reject(failure('invalid-input', '新建会话需要预期会话与 requestId'));
418
+ }
419
+ return enqueue(groupId, () => serializeRequest(requestId, async () => {
420
+ const group = groups.get(groupId);
421
+ if (!group || deletedGroupIds.has(groupId)) throw failure('not-found', '聊天不存在');
422
+ for (const otherGroupId of groups.keys()) {
423
+ if (otherGroupId !== groupId && committedChain(otherGroupId).some(item => item.requestId === requestId)) throw failure('conflict', 'requestId 已用于其他聊天');
424
+ }
425
+ const chain = committedChain(groupId);
426
+ const current = chain.at(-1);
427
+ if (!current) throw failure('conversation-corrupt', '聊天会话尚未初始化');
428
+ const replay = chain.find(item => item.requestId === requestId);
429
+ if (replay) {
430
+ if (replay.previousConversationId !== expectedConversationId) throw failure('conflict', 'requestId 已用于不同父会话');
431
+ return { created: false, conversation: replay, currentConversationId: current.conversationId, conversationVersion: current.ordinal };
432
+ }
433
+ if (current.conversationId !== expectedConversationId) throw failure('stale-conversation', '聊天已开始新会话', { currentConversationId: current.conversationId, conversationVersion: current.ordinal });
434
+ if (failedWrites.get(groupId)?.size) throw failure('persistence-failed', '聊天仍有未成功持久化的更改');
435
+ const conversation = await writeConversation({
436
+ conversationId: 'conv_' + randomUUID(), groupId, ordinal: current.ordinal + 1,
437
+ previousConversationId: current.conversationId, afterSeq: group.nextSeq ?? 0,
438
+ createdAt: now(), reason: 'user-new', requestId,
439
+ });
440
+ const stored = { ...copy(groups.get(groupId)), activeConversationId: conversation.conversationId, conversationVersion: conversation.ordinal, updatedAt: now() };
441
+ await domain.table('groups').put(groupId, stored);
442
+ publishGroup(stored);
443
+ return { created: true, conversation, currentConversationId: conversation.conversationId, conversationVersion: conversation.ordinal };
444
+ }));
445
+ },
127
446
 
128
447
  getBot(botId) {
129
448
  return bots.get(botId);
130
449
  },
131
450
  getGroup(groupId) {
132
- return groups.get(groupId);
451
+ return copy(groups.get(groupId));
133
452
  },
134
453
  getFolder(folderId) {
135
454
  return folders.get(folderId);
136
455
  },
137
456
  getMember(membershipId) {
138
- return members.get(membershipId);
457
+ return copy(members.get(membershipId));
139
458
  },
140
459
  getMeta(key) {
141
460
  return meta.get(key);
@@ -147,7 +466,7 @@ export async function openRepository(ctx, logger) {
147
466
  return [...bots.values()].sort((a, b) => a.createdAt - b.createdAt);
148
467
  },
149
468
  allGroups() {
150
- return [...groups.values()].sort((a, b) => b.updatedAt - a.updatedAt);
469
+ return [...groups.values()].map(copy).sort((a, b) => b.updatedAt - a.updatedAt);
151
470
  },
152
471
  allFolders() {
153
472
  return [...folders.values()].sort((a, b) => a.order - b.order || (a.createdAt ?? 0) - (b.createdAt ?? 0));
@@ -155,10 +474,11 @@ export async function openRepository(ctx, logger) {
155
474
  membersOf(groupId) {
156
475
  return [...members.values()]
157
476
  .filter((member) => member.groupId === groupId)
477
+ .map(copy)
158
478
  .sort((a, b) => a.order - b.order);
159
479
  },
160
480
  membersOfBot(botId) {
161
- return [...members.values()].filter((member) => member.botId === botId);
481
+ return [...members.values()].filter((member) => member.botId === botId).map(copy);
162
482
  },
163
483
  messagesOf(groupId) {
164
484
  return [...(messagesByGroup.get(groupId) ?? [])];
@@ -183,15 +503,20 @@ export async function openRepository(ctx, logger) {
183
503
  .filter((task) => task.groupId === groupId)
184
504
  .sort((a, b) => a.createdAt - b.createdAt);
185
505
  },
186
- async putTask(task) {
187
- task.updatedAt = now();
188
- tasks.set(task.taskId, task);
189
- await domain.table('tasks').put(task.taskId, task);
190
- return task;
506
+ putTask(task) {
507
+ const candidate = recordConversation(copy(task), tasks.get(task.taskId));
508
+ candidate.updatedAt = now();
509
+ return enqueue(candidate.groupId, async () => {
510
+ await domain.table('tasks').put(candidate.taskId, candidate);
511
+ tasks.set(candidate.taskId, candidate);
512
+ return candidate;
513
+ });
191
514
  },
192
515
  async deleteTask(taskId) {
516
+ const task = tasks.get(taskId);
517
+ if (!task) return;
518
+ await enqueue(task.groupId, () => domain.table('tasks').delete(taskId));
193
519
  tasks.delete(taskId);
194
- await domain.table('tasks').delete(taskId).catch((error) => warn('delete task', error));
195
520
  },
196
521
  discussionsOf(groupId) {
197
522
  return [...discussions.values()]
@@ -199,50 +524,106 @@ export async function openRepository(ctx, logger) {
199
524
  .sort((a, b) => b.createdAt - a.createdAt);
200
525
  },
201
526
  activeDiscussionOf(groupId) {
202
- return repo.discussionsOf(groupId).find((discussion) => discussion.status === 'running');
203
- },
204
- async putDiscussion(discussion) {
205
- discussion.updatedAt = now();
206
- discussions.set(discussion.discussionId, discussion);
207
- await domain.table('discussions').put(discussion.discussionId, discussion);
208
- return discussion;
527
+ const conversationId = repo.activeConversation(groupId)?.conversationId;
528
+ return repo.discussionsOf(groupId).find((discussion) => isDiscussionActive(discussion)
529
+ && repo.conversationFor(discussion)?.conversationId === conversationId);
530
+ },
531
+ putDiscussion(discussion) {
532
+ const candidate = recordConversation(copy(discussion), discussions.get(discussion.discussionId));
533
+ candidate.updatedAt = now();
534
+ return enqueue(candidate.groupId, async () => {
535
+ await domain.table('discussions').put(candidate.discussionId, candidate);
536
+ discussions.set(candidate.discussionId, candidate);
537
+ failedWrites.get(candidate.groupId)?.delete('discussion:' + candidate.discussionId);
538
+ return candidate;
539
+ });
209
540
  },
210
541
  async deleteDiscussion(discussionId) {
542
+ const discussion = discussions.get(discussionId);
543
+ if (!discussion) return;
544
+ await enqueue(discussion.groupId, () => domain.table('discussions').delete(discussionId));
211
545
  discussions.delete(discussionId);
212
- await domain.table('discussions').delete(discussionId).catch((error) => warn('delete discussion', error));
213
546
  },
214
547
 
215
548
  allocateSeq(groupId) {
216
549
  const group = groups.get(groupId);
217
550
  if (!group) throw new Error(`group ${groupId} does not exist`);
218
- group.nextSeq = (group.nextSeq ?? 0) + 1;
551
+ group.nextSeq = Math.max(groupNextSeq.get(groupId) ?? 0, group.nextSeq ?? 0) + 1;
552
+ if (!Number.isSafeInteger(group.nextSeq)) throw failure('conversation-corrupt', '聊天消息序号超出有效范围');
553
+ groupNextSeq.set(groupId, group.nextSeq);
219
554
  group.updatedAt = now();
220
- domain.table('groups').put(groupId, group).catch((error) => warn('persist group seq', error));
555
+ enqueue(groupId, async () => {
556
+ const latest = groups.get(groupId);
557
+ if (!latest || deletedGroupIds.has(groupId)) throw failure('not-found', '聊天已删除');
558
+ const stored = { ...copy(latest), ...groupHeads.get(groupId) };
559
+ await domain.table('groups').put(groupId, stored);
560
+ }, 'group').catch((error) => warn('persist group seq', error));
221
561
  return group.nextSeq;
222
562
  },
223
563
 
224
564
  appendMessage(message) {
565
+ const error = messageWriteError(message.messageId);
566
+ if (error) {
567
+ warn('persist message', error);
568
+ return Promise.resolve();
569
+ }
570
+ try { recordConversation(message, messages.get(message.messageId)); }
571
+ catch (error) { return Promise.reject(error); }
572
+ const candidate = copy(message);
225
573
  messages.set(message.messageId, message);
226
574
  const list = messagesByGroup.get(message.groupId) ?? [];
227
- list.push(message);
575
+ const existing = list.findIndex(item => item.messageId === message.messageId);
576
+ if (existing >= 0) list[existing] = message;
577
+ else list.push(message);
228
578
  sortBySeq(list);
229
579
  messagesByGroup.set(message.groupId, list);
230
- return domain.table('messages').put(message.messageId, message).catch((error) => warn('persist message', error));
580
+ return enqueue(message.groupId, () => domain.table('messages').put(candidate.messageId, candidate), 'message:' + message.messageId)
581
+ .catch((error) => warn('persist message', error));
231
582
  },
232
583
  putMessage(message) {
233
- return repo.putMessageStrict(message).catch((error) => warn('persist message', error));
584
+ return repo.putMessageStrict(message, { trackFailure: true }).catch((error) => warn('persist message', error));
234
585
  },
235
586
  // Execution references must be durable before a prompt enters the agent inbox.
236
587
  // Unlike putMessage, this path lets the caller fail closed on storage errors.
237
- putMessageStrict(message) {
238
- messages.set(message.messageId, message);
239
- const list = messagesByGroup.get(message.groupId) ?? [];
240
- const index = list.findIndex((candidate) => candidate.messageId === message.messageId);
241
- if (index >= 0) list[index] = message;
242
- else list.push(message);
243
- sortBySeq(list);
244
- messagesByGroup.set(message.groupId, list);
245
- return domain.table('messages').put(message.messageId, message);
588
+ putMessageStrict(message, { trackFailure = false } = {}) {
589
+ const error = messageWriteError(message.messageId);
590
+ if (error) return Promise.reject(error);
591
+ let candidate;
592
+ try { candidate = recordConversation(copy(message), messages.get(message.messageId)); }
593
+ catch (error) { return Promise.reject(error); }
594
+ return enqueue(candidate.groupId, async () => {
595
+ await domain.table('messages').put(candidate.messageId, candidate);
596
+ messages.set(candidate.messageId, candidate);
597
+ const list = messagesByGroup.get(candidate.groupId) ?? [];
598
+ const index = list.findIndex(item => item.messageId === candidate.messageId);
599
+ if (index >= 0) list[index] = candidate;
600
+ else list.push(candidate);
601
+ sortBySeq(list);
602
+ messagesByGroup.set(candidate.groupId, list);
603
+ failedWrites.get(candidate.groupId)?.delete('message:' + candidate.messageId);
604
+ return candidate;
605
+ }, trackFailure ? 'message:' + candidate.messageId : undefined);
606
+ },
607
+ // Preserve the legacy best-effort delete below for group/bot cleanup. An
608
+ // explicit user deletion must succeed durably before readers or SSE see it.
609
+ async deleteMessageStrict(messageId) {
610
+ if (deletingMessageIds.has(messageId)) throw Object.assign(new Error('消息正在删除'), { code: 'busy' });
611
+ const message = messages.get(messageId);
612
+ if (!message) return false;
613
+ deletingMessageIds.add(messageId);
614
+ try {
615
+ // The domain write chain orders this after any already-submitted puts.
616
+ await enqueue(message.groupId, () => domain.table('messages').delete(messageId));
617
+ failedWrites.get(message.groupId)?.delete('message:' + messageId);
618
+ deletedMessageIds.add(messageId);
619
+ messages.delete(messageId);
620
+ const list = messagesByGroup.get(message.groupId) ?? [];
621
+ const index = list.findIndex((candidate) => candidate.messageId === messageId);
622
+ if (index >= 0) list.splice(index, 1);
623
+ return true;
624
+ } finally {
625
+ deletingMessageIds.delete(messageId);
626
+ }
246
627
  },
247
628
  async deleteMessage(messageId) {
248
629
  const message = messages.get(messageId);
@@ -251,7 +632,7 @@ export async function openRepository(ctx, logger) {
251
632
  const list = messagesByGroup.get(message.groupId) ?? [];
252
633
  const index = list.findIndex((candidate) => candidate.messageId === messageId);
253
634
  if (index >= 0) list.splice(index, 1);
254
- await domain.table('messages').delete(messageId).catch((error) => warn('delete message', error));
635
+ await enqueue(message.groupId, () => domain.table('messages').delete(messageId), 'message:' + messageId).catch((error) => warn('delete message', error));
255
636
  return true;
256
637
  },
257
638
 
@@ -265,15 +646,46 @@ export async function openRepository(ctx, logger) {
265
646
  bots.delete(botId);
266
647
  await domain.table('bots').delete(botId).catch((error) => warn('delete bot', error));
267
648
  },
268
- async putGroup(group) {
269
- group.updatedAt = now();
270
- groups.set(group.groupId, group);
271
- await domain.table('groups').put(group.groupId, group);
272
- return group;
273
- },
274
- async deleteGroup(groupId) {
275
- groups.delete(groupId);
276
- await domain.table('groups').delete(groupId).catch((error) => warn('delete group', error));
649
+ putGroup(group) {
650
+ const candidate = copy(group);
651
+ return enqueue(candidate.groupId, async () => {
652
+ if (deletedGroupIds.has(candidate.groupId)) throw failure('not-found', '聊天已删除');
653
+ const latest = groups.get(candidate.groupId);
654
+ const head = groupHeads.get(candidate.groupId);
655
+ const stored = { ...copy(latest ?? {}), ...candidate, ...head, nextSeq: Math.max(latest?.nextSeq ?? 0, candidate.nextSeq ?? 0), updatedAt: now() };
656
+ if (!head?.activeConversationId) {
657
+ // Publish a new group only after its baseline and pointer are durable.
658
+ delete stored.activeConversationId;
659
+ delete stored.conversationVersion;
660
+ await initializeConversation(stored);
661
+ return copy(groups.get(candidate.groupId));
662
+ }
663
+ await domain.table('groups').put(stored.groupId, stored);
664
+ publishGroup(stored);
665
+ failedWrites.get(stored.groupId)?.delete('group');
666
+ return copy(stored);
667
+ });
668
+ },
669
+ deleteGroup(groupId) {
670
+ return enqueue(groupId, async () => {
671
+ await domain.table('groups').delete(groupId);
672
+ deletedGroupIds.add(groupId);
673
+ groups.delete(groupId);
674
+ groupHeads.delete(groupId);
675
+ groupNextSeq.delete(groupId);
676
+ failedWrites.delete(groupId);
677
+ // Delete the authority first. A crash during cleanup leaves only invisible
678
+ // orphan metadata, never an existing group with a broken history chain.
679
+ for (const binding of [...sessionBindings.values()].filter(item => item.groupId === groupId)) {
680
+ await domain.table('session_bindings').delete(binding.bindingId);
681
+ sessionBindings.delete(binding.bindingId);
682
+ bindingsBySession.delete(binding.sessionId);
683
+ }
684
+ for (const conversation of [...conversations.values()].filter(item => item.groupId === groupId)) {
685
+ await domain.table('conversations').delete(conversation.conversationId);
686
+ conversations.delete(conversation.conversationId);
687
+ }
688
+ });
277
689
  },
278
690
  async putFolder(folder) {
279
691
  folder.updatedAt = now();
@@ -285,20 +697,63 @@ export async function openRepository(ctx, logger) {
285
697
  folders.delete(folderId);
286
698
  await domain.table('folders').delete(folderId).catch((error) => warn('delete folder', error));
287
699
  },
288
- async putMember(member) {
289
- member.updatedAt = now();
290
- members.set(member.membershipId, member);
291
- await domain.table('members').put(member.membershipId, member);
292
- return member;
293
- },
294
- async deleteMember(membershipId) {
295
- members.delete(membershipId);
296
- await domain.table('members').delete(membershipId).catch((error) => warn('delete member', error));
700
+ putMember(member) {
701
+ return repo.putMemberStrict(member, { preserveSession: true });
702
+ },
703
+ putMemberStrict(member, options = {}) {
704
+ const candidate = copy(member);
705
+ const expectedConversationId = options.expectedConversationId ?? repo.activeConversation(candidate.groupId)?.conversationId;
706
+ const captured = memberMappings.get(candidate.membershipId);
707
+ const expectedSessionId = options.expectedSessionId ?? captured?.sessionId;
708
+ return enqueue(candidate.groupId, async () => {
709
+ if (deletedGroupIds.has(candidate.groupId) || deletedMemberIds.has(candidate.membershipId)) throw failure('not-found', '聊天或成员已删除');
710
+ const current = repo.activeConversation(candidate.groupId);
711
+ if (!current) throw failure('conversation-corrupt', '聊天会话尚未初始化');
712
+ if (current.conversationId !== expectedConversationId) throw failure('stale-conversation', '成员写入跨越了会话切换');
713
+ const latest = memberMappings.get(candidate.membershipId);
714
+ if (latest && (latest.groupId !== candidate.groupId || latest.botId !== candidate.botId)) throw failure('conflict', '不可改变成员归属');
715
+ if (latest && latest.sessionId !== expectedSessionId) throw failure('stale-session', '成员 Session 已轮换');
716
+ const stored = { ...copy(latest ?? {}), ...candidate, updatedAt: now() };
717
+ if (latest && options.preserveSession) {
718
+ // Settings and old cursor callbacks must never restore an older mapping.
719
+ stored.sessionId = latest.sessionId;
720
+ stored.sessionConversationId = latest.sessionConversationId;
721
+ stored.previousSessionIds = latest.previousSessionIds;
722
+ stored.lastSeenSeq = latest.sessionId === candidate.sessionId && latest.sessionConversationId === candidate.sessionConversationId
723
+ ? Math.max(latest.lastSeenSeq ?? 0, candidate.lastSeenSeq ?? 0) : latest.lastSeenSeq;
724
+ } else {
725
+ stored.sessionConversationId ??= latest?.sessionId === stored.sessionId ? latest.sessionConversationId : current.conversationId;
726
+ if (latest && (latest.sessionId !== stored.sessionId || latest.sessionConversationId !== stored.sessionConversationId)) {
727
+ if (stored.sessionConversationId !== current.conversationId || latest.previousSessionIds?.includes(stored.sessionId)) throw failure('stale-session', '不能恢复历史 Session 为当前执行');
728
+ stored.previousSessionIds = [...new Set([...(latest.previousSessionIds ?? []), ...(stored.previousSessionIds ?? []), latest.sessionId].filter(id => id && id !== stored.sessionId))];
729
+ stored.lastSeenSeq = Math.max(current.afterSeq, stored.lastSeenSeq ?? current.afterSeq);
730
+ } else if (latest) stored.lastSeenSeq = Math.max(latest.lastSeenSeq ?? 0, stored.lastSeenSeq ?? 0);
731
+ }
732
+ if (!latest) {
733
+ if (stored.sessionConversationId !== current.conversationId) throw failure('stale-conversation', '新成员必须加入当前会话');
734
+ stored.lastSeenSeq = Math.max(current.afterSeq, stored.lastSeenSeq ?? 0);
735
+ }
736
+ if (!repo.conversationFor({ groupId: stored.groupId, conversationId: stored.sessionConversationId })) throw failure('conversation-corrupt', '成员 Session 归属无效');
737
+ // Do not mutate caller-owned objects before either durable write succeeds.
738
+ await writeBinding(stored, { sessionId: stored.sessionId, conversationId: stored.sessionConversationId, reason: latest ? 'session-mapping' : 'member-created' });
739
+ await domain.table('members').put(stored.membershipId, stored);
740
+ publishMember(stored);
741
+ return copy(stored);
742
+ });
743
+ },
744
+ deleteMember(membershipId) {
745
+ const member = memberMappings.get(membershipId) ?? members.get(membershipId);
746
+ if (!member) return Promise.resolve();
747
+ return enqueue(member.groupId, async () => {
748
+ await domain.table('members').delete(membershipId);
749
+ deletedMemberIds.add(membershipId);
750
+ members.delete(membershipId);
751
+ memberMappings.delete(membershipId);
752
+ // Historical sessionBindings intentionally outlive the membership.
753
+ });
297
754
  },
298
755
  touchMember(member) {
299
- member.updatedAt = now();
300
- members.set(member.membershipId, member);
301
- domain.table('members').put(member.membershipId, member).catch((error) => warn('touch member', error));
756
+ return repo.putMember(member).catch((error) => warn('touch member', error));
302
757
  },
303
758
  async putMeta(key, value) {
304
759
  await domain.table('meta').put(key, value);
@@ -466,6 +921,24 @@ export async function openRepository(ctx, logger) {
466
921
  version = 6;
467
922
  applied.push(5);
468
923
  }
924
+ if (version < 7) {
925
+ // Each group commits its baseline only after its members and ownership are
926
+ // durable. A failed migration leaves schema=6 and can resume after restart.
927
+ for (const group of [...groups.values()]) await repo.ensureConversation(group.groupId);
928
+ validateConversations();
929
+ await repo.putMeta('schema', { version: 7, migratedAt: now() });
930
+ version = 7;
931
+ applied.push(6);
932
+ }
933
+ if (version < 8) {
934
+ // Legacy runs retain their original transcript and round semantics. Their
935
+ // missing step journal cannot be reconstructed reliably from messages.
936
+ for (const discussion of discussions.values()) await repo.putDiscussion({ ...discussion });
937
+ await repo.putMeta('schema', { version: 8, migratedAt: now() });
938
+ version = 8;
939
+ applied.push(7);
940
+ }
941
+ validateConversations();
469
942
  return { version, applied };
470
943
  },
471
944
  health() {
@@ -481,13 +954,22 @@ export async function openRepository(ctx, logger) {
481
954
  messages: messages.size,
482
955
  discussions: discussions.size,
483
956
  tasks: tasks.size,
957
+ conversations: [...groups.keys()].reduce((sum, groupId) => sum + repo.conversationsOf(groupId).length, 0),
958
+ sessionBindings: sessionBindings.size,
484
959
  },
485
960
  };
486
961
  },
487
962
  async close() {
963
+ while (groupWrites.size) await Promise.all([...groupWrites.values()]);
488
964
  await domain.close();
489
965
  },
490
966
  };
491
967
 
968
+ try {
969
+ if (repo.schemaVersion() >= 7) validateConversations();
970
+ } catch (error) {
971
+ await domain.close().catch(() => {});
972
+ throw error;
973
+ }
492
974
  return repo;
493
975
  }