@whaletalk/dsh-codex-collab 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * codex-outbox.mjs —— DSH → Codex 的本地持久化收件箱(纯 Node,无依赖,可单测)。
3
+ *
4
+ * 为什么存在:M0 已证明本机 Windows 上**没有可附着的桌面端点**(见
5
+ * docs/codex-desktop-attachment-evidence.md),因此"DSH 主动发消息并触发 Codex 回复"
6
+ * 的自动唤醒部分处于 blocked。这里交付的是它可独立验证的那一半:一个不会丢、不会重复、
7
+ * 重启可恢复的消息收件箱。Codex 侧(或用户)主动来读、确认收到、回复回填。
8
+ *
9
+ * 可靠性设计(对应本轮修复的 P1-1 / P1-2):
10
+ * - **记录边界**:追加前先看末字节;不是换行(上次写入被截断)就先补一个换行,把残尾封成
11
+ * 一条独立的坏行(读取时跳过),保证新事件从干净的行首开始——否则新事件会被拼进残行、
12
+ * 一起被跳过,表现为"写入成功却查不到"。
13
+ * - **跨进程写事务**:绑定更新、幂等检查、seq 分配、事件追加全部在写锁内串行化。
14
+ * 锁就是**一个共享路径** `<root>/.write.lock`,互斥只来自**一次原子独占创建**
15
+ * (硬链接发布 / `wx` 独占创建):谁创建成功谁进临界区。**在线竞争期间绝不自动回收**
16
+ * ——"检查共享路径再删除"无法原子化,回收者可能删掉别人刚建的新锁。因此持有者活着就
17
+ * 有限等待(超时报 lock-timeout),持有者已死报 lock-stale-holder、元数据不可确认报
18
+ * lock-unverified-holder,两者都是**可恢复错误 + 人工清理**,宁可阻塞也不冒双写者风险。
19
+ * - **原子替换**:绑定表写临时文件再 rename;读取时"文件存在但读不动/解析失败"直接抛错,
20
+ * 绝不退化成空收件箱(否则权限/I-O 故障会伪装成"没有绑定")。
21
+ * - 能力如实上报:canWakeIdle=false、deliveryMode='inbox',绝不宣称自动唤醒可用。
22
+ *
23
+ * 存储位置默认在用户目录(`~/.dsh-codex-inbox`),**不写进仓库**;`--root`/环境变量可改。
24
+ */
25
+ import fs from 'node:fs'
26
+ import os from 'node:os'
27
+ import path from 'node:path'
28
+ import { createFileLock } from './fs-lock.mjs'
29
+
30
+ export const OUTBOX_FORMAT_VERSION = 1
31
+
32
+ /** 消息状态。delivery-unknown 是留给将来接入适配器时"发起后连接断了、无法确认服务端是否接受"的。 */
33
+ export const MESSAGE_STATES = {
34
+ QUEUED: 'queued',
35
+ DELIVERED: 'delivered',
36
+ ACKED: 'acked',
37
+ FAILED: 'failed',
38
+ UNKNOWN: 'delivery-unknown',
39
+ }
40
+
41
+ /** 稳定的失败原因,便于调用方分支处理(不解析人类文案)。 */
42
+ export const INBOX_REASONS = {
43
+ UNBOUND_TARGET: 'unbound-target',
44
+ BINDING_THREAD_MISMATCH: 'binding-thread-mismatch',
45
+ INVALID_INPUT: 'invalid-input',
46
+ IDEMPOTENCY_CONFLICT: 'idempotency-conflict',
47
+ ILLEGAL_STATE: 'illegal-state-transition',
48
+ UNKNOWN_MESSAGE: 'unknown-message',
49
+ /** 写事务拿不到锁(且持有者活着):明确失败,不假装写成功,也不抢别人的锁。 */
50
+ LOCK_TIMEOUT: 'lock-timeout',
51
+ /**
52
+ * 锁的持有者**已死**。不自动回收:在共享路径上"检查再删除"存在 ABA/TOCTOU
53
+ * (回收者可能删掉别人刚创建的新锁),无法证明安全。fail closed,给人工恢复步骤。
54
+ */
55
+ LOCK_STALE_HOLDER: 'lock-stale-holder',
56
+ /**
57
+ * 锁存在但**无法确认持有者**(内容空/损坏/不可读)。同样不以任何时间规则回收。
58
+ */
59
+ LOCK_UNVERIFIED_HOLDER: 'lock-unverified-holder',
60
+ /** 存储存在但读不动(权限/IO):必须报错,不能当成空收件箱。 */
61
+ STORE_UNREADABLE: 'store-unreadable',
62
+ /** 存储被写坏(JSON 解析失败/结构不对):同上,不静默清空。 */
63
+ STORE_CORRUPT: 'store-corrupt',
64
+ /** 内部不变量:事件写完后读不到自己的消息。宁可报错也不返回 undefined。 */
65
+ WRITE_FAILED: 'write-failed',
66
+ }
67
+
68
+ /**
69
+ * 本机桌面接入能力如实描述。M0 结论:无可附着端点 → 只能收件箱模式。
70
+ * 三个能力必须分清:canWakeIdle(自动唤醒空闲聊天)、canObserveTurns(能观察回合事件)、
71
+ * deliveryMode='inbox'(用户/Codex 主动来取)。绝不把收件箱说成唤醒。
72
+ */
73
+ export const INBOX_CAPABILITIES = {
74
+ available: false,
75
+ deliveryMode: 'inbox',
76
+ canWakeIdle: false,
77
+ canObserveTurns: false,
78
+ reason: 'desktop-attachment-unavailable',
79
+ detail: 'desktop app-server runs stdio:// owned by its desktop parent; no attachable endpoint on this host (M0, docs/codex-desktop-attachment-evidence.md)',
80
+ }
81
+
82
+ /**
83
+ * 写锁就是**一个共享路径** `<root>/.write.lock`,实现复用 `fs-lock.mjs`(同一把锁的算法也被
84
+ * 任务关联存储使用)。互斥只靠一次原子独占创建;**在线竞争期间绝不自动回收**:
85
+ * - 持有者活着 → 有限等待,超时报 lock-timeout;
86
+ * - 持有者已死 → 二次确认后报 lock-stale-holder(可恢复错误,人工清理);
87
+ * - 元数据空/损坏 → 报 lock-unverified-holder(可恢复错误,人工清理)。
88
+ * 上一版"编号 claim + 选举 + 自动回收"被证明编号会被迟到发布者复用,已整块删除。
89
+ */
90
+ const LOCK_FILE_NAME = '.write.lock'
91
+ const DEFAULT_LOCK_TIMEOUT_MS = 10000
92
+
93
+ const now = () => Date.now()
94
+ const uid = (prefix) => prefix + '-' + now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
95
+
96
+ /** 收件箱根目录:显式参数 > 环境变量 > 用户目录。绝不默认落到仓库里。 */
97
+ export const resolveRoot = (explicit) => {
98
+ if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
99
+ const fromEnv = process.env.DSH_CODEX_INBOX_DIR
100
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return path.resolve(fromEnv)
101
+ return path.join(os.homedir(), '.dsh-codex-inbox')
102
+ }
103
+
104
+ const fail = (reason, message, extra) => Object.assign(new Error(message), { reason, ...(extra === undefined ? {} : extra) })
105
+
106
+ /** 读取事件日志。坏行跳过(半行/损坏行不该毁掉整个收件箱),但不影响写入侧的边界修复。 */
107
+ const readEvents = (file) => {
108
+ let text = ''
109
+ try {
110
+ text = fs.readFileSync(file, 'utf8')
111
+ } catch (e) {
112
+ if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return []
113
+ throw fail(INBOX_REASONS.STORE_UNREADABLE, 'cannot read ' + file + ': ' + e.message)
114
+ }
115
+ const out = []
116
+ for (const line of text.split('\n')) {
117
+ const trimmed = line.trim()
118
+ if (trimmed === '') continue
119
+ try {
120
+ const value = JSON.parse(trimmed)
121
+ if (value !== null && typeof value === 'object' && typeof value.type === 'string') out.push(value)
122
+ } catch (e) { /* 残尾/坏行:跳过 */ }
123
+ }
124
+ return out
125
+ }
126
+
127
+ /** 折叠事件日志得到当前状态。 */
128
+ const foldMessages = (events) => {
129
+ const messages = new Map()
130
+ for (const event of events) {
131
+ if (event.type === 'enqueue') {
132
+ if (typeof event.messageId !== 'string') continue
133
+ messages.set(event.messageId, {
134
+ messageId: event.messageId,
135
+ bindingId: event.bindingId,
136
+ codexThreadId: event.codexThreadId,
137
+ sourceSessionId: event.sourceSessionId,
138
+ text: event.text,
139
+ idempotencyKey: event.idempotencyKey,
140
+ deliver: event.deliver,
141
+ seq: event.seq,
142
+ state: MESSAGE_STATES.QUEUED,
143
+ createdAt: event.at,
144
+ updatedAt: event.at,
145
+ deliveredAt: undefined,
146
+ ackedAt: undefined,
147
+ turnId: undefined,
148
+ reply: undefined,
149
+ failureReason: undefined,
150
+ })
151
+ continue
152
+ }
153
+ const message = messages.get(event.messageId)
154
+ if (message === undefined) continue
155
+ if (event.type === 'delivered') {
156
+ message.state = MESSAGE_STATES.DELIVERED
157
+ message.deliveredAt = event.at
158
+ message.deliveredBy = event.by
159
+ } else if (event.type === 'acked') {
160
+ message.state = MESSAGE_STATES.ACKED
161
+ message.ackedAt = event.at
162
+ if (event.reply !== undefined) message.reply = event.reply
163
+ if (event.turnId !== undefined) message.turnId = event.turnId
164
+ } else if (event.type === 'failed') {
165
+ message.state = MESSAGE_STATES.FAILED
166
+ message.failureReason = event.reason
167
+ } else if (event.type === 'unknown') {
168
+ message.state = MESSAGE_STATES.UNKNOWN
169
+ message.failureReason = event.reason
170
+ }
171
+ message.updatedAt = event.at
172
+ }
173
+ return messages
174
+ }
175
+
176
+ /**
177
+ * 打开收件箱。所有方法都是同步的:文件很小、语义要确定,同步反而更不容易出错。
178
+ * 写入路径全部经过一把跨进程文件锁(P1-2),读路径不加锁。
179
+ * @param {{root?: string, lockTimeoutMs?: number}} options
180
+ */
181
+ export const openOutbox = (options = {}) => {
182
+ const root = resolveRoot(options.root)
183
+ const lockTimeoutMs = typeof options.lockTimeoutMs === 'number' && options.lockTimeoutMs > 0 ? options.lockTimeoutMs : DEFAULT_LOCK_TIMEOUT_MS
184
+ const bindingsFile = path.join(root, 'bindings.json')
185
+ const eventsFile = path.join(root, 'messages.jsonl')
186
+ const lockFile = path.join(root, LOCK_FILE_NAME)
187
+ // 锁算法在 fs-lock.mjs(同一把锁也被任务关联存储使用):独占创建 + 从不自动回收,
188
+ // 失败原因是 lock-timeout / lock-stale-holder / lock-unverified-holder(与 INBOX_REASONS 同名同值)。
189
+ const lock = createFileLock({ lockFile, timeoutMs: lockTimeoutMs })
190
+
191
+ const ensureRoot = () => { fs.mkdirSync(root, { recursive: true }) }
192
+
193
+ /** 在写锁内执行;异常路径也保证释放。 */
194
+ const withLock = (fn) => lock.withLock(fn)
195
+
196
+ /**
197
+ * 追加一个事件。**记录边界**:末字节不是换行时先补换行,把残尾封成独立坏行,
198
+ * 保证新事件从干净行首开始(否则会和残尾拼成一行、一起被跳过)。
199
+ * 必须在写锁内调用。
200
+ */
201
+ const appendEventLocked = (event) => {
202
+ ensureRoot()
203
+ let size = 0
204
+ try { size = fs.statSync(eventsFile).size } catch (e) { size = 0 }
205
+ if (size > 0) {
206
+ let last = 0
207
+ const fd = fs.openSync(eventsFile, 'r')
208
+ try {
209
+ const buf = Buffer.alloc(1)
210
+ fs.readSync(fd, buf, 0, 1, size - 1)
211
+ last = buf[0]
212
+ } finally {
213
+ fs.closeSync(fd)
214
+ }
215
+ if (last !== 0x0a) fs.appendFileSync(eventsFile, '\n', 'utf8')
216
+ }
217
+ fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n', 'utf8')
218
+ }
219
+
220
+ /**
221
+ * 读绑定表。**文件存在但读不动/解析失败 → 抛错**,绝不退化成空表:
222
+ * 权限或 I/O 故障伪装成"没有绑定"会让调用方以为未绑定而放弃,或更糟,以为无人使用。
223
+ */
224
+ const loadBindings = () => {
225
+ let text
226
+ try {
227
+ text = fs.readFileSync(bindingsFile, 'utf8')
228
+ } catch (e) {
229
+ if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return {}
230
+ throw fail(INBOX_REASONS.STORE_UNREADABLE, 'cannot read ' + bindingsFile + ': ' + e.message)
231
+ }
232
+ let doc
233
+ try {
234
+ doc = JSON.parse(text)
235
+ } catch (e) {
236
+ throw fail(INBOX_REASONS.STORE_CORRUPT, 'bindings file is not valid JSON: ' + e.message)
237
+ }
238
+ if (doc === null || typeof doc !== 'object' || doc.bindings === null || typeof doc.bindings !== 'object' || Array.isArray(doc.bindings)) {
239
+ throw fail(INBOX_REASONS.STORE_CORRUPT, 'bindings file has unexpected shape')
240
+ }
241
+ return doc.bindings
242
+ }
243
+
244
+ /** 原子替换绑定表:先写临时文件再 rename,避免中途崩溃把文件截断。必须在写锁内调用。 */
245
+ const saveBindingsLocked = (bindings) => {
246
+ ensureRoot()
247
+ const tmp = bindingsFile + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2, 8)
248
+ fs.writeFileSync(tmp, JSON.stringify({ formatVersion: OUTBOX_FORMAT_VERSION, bindings }, null, 2) + '\n', 'utf8')
249
+ try {
250
+ fs.renameSync(tmp, bindingsFile)
251
+ } catch (e) {
252
+ try { fs.unlinkSync(tmp) } catch (e2) { /* ignore */ }
253
+ throw fail(INBOX_REASONS.STORE_UNREADABLE, 'cannot replace bindings file: ' + e.message)
254
+ }
255
+ }
256
+
257
+ const loadMessages = () => foldMessages(readEvents(eventsFile))
258
+
259
+ const requireMessage = (messages, messageId) => {
260
+ const message = messages.get(messageId)
261
+ if (message === undefined) throw fail(INBOX_REASONS.UNKNOWN_MESSAGE, 'unknown messageId ' + messageId)
262
+ return message
263
+ }
264
+
265
+ const box = {
266
+ root,
267
+ bindingsFile,
268
+ eventsFile,
269
+ lockFile,
270
+ capabilities: () => ({ ...INBOX_CAPABILITIES, root }),
271
+
272
+ /**
273
+ * 显式登记一个绑定。**只接受显式调用**:不按标题猜、不从最近使用里挑默认目标。
274
+ * 同一个 bindingId 再次登记必须是同一个 threadId,否则报错(防止悄悄改投别处)。
275
+ */
276
+ bind({ bindingId, codexThreadId, sourceSessionId, note }) {
277
+ if (typeof bindingId !== 'string' || bindingId.trim() === '') throw fail(INBOX_REASONS.INVALID_INPUT, 'bindingId is required')
278
+ if (typeof codexThreadId !== 'string' || codexThreadId.trim() === '') throw fail(INBOX_REASONS.INVALID_INPUT, 'codexThreadId is required')
279
+ return withLock(() => {
280
+ const bindings = loadBindings()
281
+ const existing = bindings[bindingId]
282
+ if (existing !== undefined && existing.codexThreadId !== codexThreadId) {
283
+ throw fail(INBOX_REASONS.BINDING_THREAD_MISMATCH, 'binding ' + bindingId + ' already points at thread ' + existing.codexThreadId)
284
+ }
285
+ const at = now()
286
+ bindings[bindingId] = {
287
+ bindingId,
288
+ codexThreadId,
289
+ sourceSessionId: typeof sourceSessionId === 'string' && sourceSessionId !== '' ? sourceSessionId : (existing === undefined ? undefined : existing.sourceSessionId),
290
+ note: typeof note === 'string' && note !== '' ? note : (existing === undefined ? undefined : existing.note),
291
+ createdAt: existing === undefined ? at : existing.createdAt,
292
+ updatedAt: at,
293
+ capabilities: { ...INBOX_CAPABILITIES },
294
+ }
295
+ saveBindingsLocked(bindings)
296
+ return bindings[bindingId]
297
+ })
298
+ },
299
+
300
+ listBindings() {
301
+ const bindings = loadBindings()
302
+ return Object.values(bindings).sort((a, b) => a.bindingId.localeCompare(b.bindingId))
303
+ },
304
+
305
+ /**
306
+ * 写入一条待投递消息。写盘成功才返回 queued(计划 §6)。
307
+ * 幂等:同 binding + 同 idempotencyKey 且正文一致 → 返回原消息(duplicate=true);
308
+ * 正文不一致 → idempotency-conflict(不覆盖、不新建)。
309
+ * 幂等检查 + seq 分配 + 追加在同一把跨进程写锁内完成,否则两个进程会各自读到旧快照。
310
+ */
311
+ enqueue({ bindingId, text, idempotencyKey, sourceSessionId, deliver }) {
312
+ if (typeof bindingId !== 'string' || bindingId.trim() === '') throw fail(INBOX_REASONS.INVALID_INPUT, 'bindingId is required')
313
+ if (typeof text !== 'string' || text.trim() === '') throw fail(INBOX_REASONS.INVALID_INPUT, 'text is required')
314
+ const mode = deliver === undefined ? 'queue' : deliver
315
+ if (mode !== 'queue' && mode !== 'steer') throw fail(INBOX_REASONS.INVALID_INPUT, 'deliver must be queue or steer')
316
+ const key = typeof idempotencyKey === 'string' && idempotencyKey !== '' ? idempotencyKey : undefined
317
+
318
+ return withLock(() => {
319
+ const bindings = loadBindings()
320
+ const binding = bindings[bindingId]
321
+ if (binding === undefined) {
322
+ // 未绑定一律拒绝:绝不静默新建目标(计划 §5)。
323
+ throw fail(INBOX_REASONS.UNBOUND_TARGET, 'binding ' + bindingId + ' is not registered; bind an explicit threadId first')
324
+ }
325
+ const messages = loadMessages()
326
+ if (key !== undefined) {
327
+ for (const message of messages.values()) {
328
+ if (message.bindingId !== bindingId || message.idempotencyKey !== key) continue
329
+ if (message.text === text) return { message: { ...message }, duplicate: true }
330
+ throw fail(INBOX_REASONS.IDEMPOTENCY_CONFLICT, 'idempotencyKey ' + key + ' already used with different text', { messageId: message.messageId })
331
+ }
332
+ }
333
+ let seq = 0
334
+ for (const message of messages.values()) {
335
+ if (message.bindingId === bindingId && typeof message.seq === 'number' && message.seq > seq) seq = message.seq
336
+ }
337
+ const at = now()
338
+ const messageId = uid('msg')
339
+ appendEventLocked({
340
+ type: 'enqueue',
341
+ formatVersion: OUTBOX_FORMAT_VERSION,
342
+ messageId,
343
+ bindingId,
344
+ codexThreadId: binding.codexThreadId,
345
+ sourceSessionId: typeof sourceSessionId === 'string' && sourceSessionId !== '' ? sourceSessionId : binding.sourceSessionId,
346
+ text,
347
+ idempotencyKey: key,
348
+ deliver: mode,
349
+ seq: seq + 1,
350
+ at,
351
+ })
352
+ // 写完必须能查到;查不到就是内部不变量被破坏,宁可报错也不返回 undefined。
353
+ const written = foldMessages(readEvents(eventsFile)).get(messageId)
354
+ if (written === undefined) {
355
+ throw fail(INBOX_REASONS.WRITE_FAILED, 'event was appended but could not be read back; the record boundary is broken', { messageId })
356
+ }
357
+ return { message: written, duplicate: false }
358
+ })
359
+ },
360
+
361
+ listMessages(filter = {}) {
362
+ const messages = Array.from(loadMessages().values())
363
+ const bindingId = filter.bindingId
364
+ const state = filter.state
365
+ const filtered = messages.filter((m) => (bindingId === undefined || m.bindingId === bindingId) && (state === undefined || m.state === state))
366
+ filtered.sort((a, b) => (a.seq - b.seq) || (a.createdAt - b.createdAt))
367
+ const limit = typeof filter.limit === 'number' && filter.limit > 0 ? filter.limit : undefined
368
+ return limit === undefined ? filtered : filtered.slice(0, limit)
369
+ },
370
+
371
+ /** 待投递(还没被 Codex 侧取走的)消息,按绑定内顺序。 */
372
+ pending(bindingId) {
373
+ return this.listMessages({ bindingId, state: MESSAGE_STATES.QUEUED })
374
+ },
375
+
376
+ getMessage(messageId) {
377
+ return requireMessage(loadMessages(), messageId)
378
+ },
379
+
380
+ markDelivered(messageId, meta = {}) {
381
+ return withLock(() => {
382
+ const message = requireMessage(loadMessages(), messageId)
383
+ if (message.state !== MESSAGE_STATES.QUEUED) {
384
+ throw fail(INBOX_REASONS.ILLEGAL_STATE, 'cannot mark delivered from state ' + message.state)
385
+ }
386
+ appendEventLocked({ type: 'delivered', messageId, by: meta.by, at: now() })
387
+ return requireMessage(loadMessages(), messageId)
388
+ })
389
+ },
390
+
391
+ /** Codex 侧确认已处理;可回填回复正文与 turnId,把结果关联回这条 messageId。 */
392
+ ack(messageId, meta = {}) {
393
+ return withLock(() => {
394
+ const message = requireMessage(loadMessages(), messageId)
395
+ if (message.state === MESSAGE_STATES.ACKED) return message
396
+ if (message.state === MESSAGE_STATES.FAILED) {
397
+ throw fail(INBOX_REASONS.ILLEGAL_STATE, 'cannot ack a failed message')
398
+ }
399
+ appendEventLocked({ type: 'acked', messageId, reply: meta.reply, turnId: meta.turnId, by: meta.by, at: now() })
400
+ return requireMessage(loadMessages(), messageId)
401
+ })
402
+ },
403
+
404
+ markFailed(messageId, reason) {
405
+ return withLock(() => {
406
+ const message = requireMessage(loadMessages(), messageId)
407
+ if (message.state === MESSAGE_STATES.ACKED) {
408
+ throw fail(INBOX_REASONS.ILLEGAL_STATE, 'cannot fail an acked message')
409
+ }
410
+ appendEventLocked({ type: 'failed', messageId, reason: reason === undefined ? 'unspecified' : reason, at: now() })
411
+ return requireMessage(loadMessages(), messageId)
412
+ })
413
+ },
414
+
415
+ /**
416
+ * 留给将来适配器:请求已发出但连接断了、无法确认服务端是否接受。
417
+ * 明确标成未知而不是失败,避免调用方把它当成"没发出去"而盲目重发(计划 §6)。
418
+ */
419
+ markUnknown(messageId, reason) {
420
+ return withLock(() => {
421
+ const message = requireMessage(loadMessages(), messageId)
422
+ if (message.state === MESSAGE_STATES.ACKED) {
423
+ throw fail(INBOX_REASONS.ILLEGAL_STATE, 'cannot mark unknown after ack')
424
+ }
425
+ appendEventLocked({ type: 'unknown', messageId, reason: reason === undefined ? 'delivery-unconfirmed' : reason, at: now() })
426
+ return requireMessage(loadMessages(), messageId)
427
+ })
428
+ },
429
+ }
430
+
431
+ return box
432
+ }