dsh-conversation-recycle-bin 0.2.1 → 0.2.2

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.
@@ -78,6 +78,7 @@ export async function performSessionDelete({
78
78
  config,
79
79
  now = Date.now,
80
80
  actor = 'local-user',
81
+ source = 'web-ui',
81
82
  requestId = null,
82
83
  }) {
83
84
  const startedAt = now()
@@ -142,10 +143,21 @@ export async function performSessionDelete({
142
143
  name: 'messages',
143
144
  run: async () => {
144
145
  const moved = await ops.sessionLog.moveToTrash(session, dir)
146
+ try {
147
+ // Record the move immediately. A crash between the move and the
148
+ // final manifest write used to leave a `pending` entry that knew
149
+ // nothing about the log sitting in its directory: discard refused
150
+ // it, restore had nothing to restore, and the payload was stranded.
151
+ await trash.update(entry.trashId, { sessionLog: moved })
152
+ } catch (error) {
153
+ await ops.sessionLog.restoreEntry(moved).catch(() => {})
154
+ throw error
155
+ }
145
156
  entry.sessionLog = moved
146
157
  return async () => {
147
158
  await ops.sessionLog.restoreEntry(moved)
148
159
  entry.sessionLog = null
160
+ await trash.update(entry.trashId, { sessionLog: null }).catch(() => {})
149
161
  }
150
162
  },
151
163
  })
@@ -190,7 +202,7 @@ export async function performSessionDelete({
190
202
  actor,
191
203
  requestId,
192
204
  durationMs: now() - startedAt,
193
- source: 'web-ui',
205
+ source,
194
206
  })
195
207
  },
196
208
  })
@@ -226,7 +238,7 @@ export async function performSessionDelete({
226
238
  })),
227
239
  },
228
240
  durationMs: now() - startedAt,
229
- source: 'web-ui',
241
+ source,
230
242
  })
231
243
  .catch(() => {})
232
244
  throw toDeleteError(result, { sessionId: session.id, title: entry.title })
@@ -263,26 +275,34 @@ const RESTORABLE_STATUSES = new Set(['trashed', 'pending', 'failed', 'restoring'
263
275
  * @param {string} [input.actor]
264
276
  * @param {string} [input.requestId]
265
277
  */
266
- export async function performRestore({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', requestId = null }) {
278
+ export async function performRestore({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
267
279
  const previousStatus = entry.status
268
280
  if (!RESTORABLE_STATUSES.has(previousStatus)) {
269
281
  throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry is not in a restorable state.', { trashId: entry.trashId, status: previousStatus })
270
282
  }
271
- const hasLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
283
+ const sessionLog = entry.sessionLog !== null && entry.sessionLog !== undefined && typeof entry.sessionLog === 'object'
284
+ ? entry.sessionLog
285
+ : null
272
286
  const catalog = entry.catalog !== null && entry.catalog !== undefined && typeof entry.catalog === 'object' ? entry.catalog : null
273
287
  const hasMemory = entry.includeMemory === true && entry.memory !== null && typeof entry.memory === 'object' && (entry.memory.total ?? 0) > 0
274
- // Re-attaching a hard-deleted conversation whose log is missing would leave a
288
+ const canStat = typeof ops.pathExists === 'function'
289
+ const logInTrash = sessionLog !== null && (!canStat || await ops.pathExists(sessionLog.to))
290
+ const logInPlace = sessionLog !== null && canStat && await ops.pathExists(sessionLog.from)
291
+ const logMissing = sessionLog !== null && !logInTrash && !logInPlace
292
+ // Re-attaching a hard-deleted conversation whose log is nowhere would leave a
275
293
  // conversation with no messages on disk, so that combination stays refused.
276
- if (catalog !== null && entry.mode === 'hard' && !hasLog) {
294
+ // A log that is already back in place is fine: an interrupted purge can leave
295
+ // exactly that state.
296
+ if (catalog !== null && entry.mode === 'hard' && (sessionLog === null || logMissing)) {
277
297
  throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has no session-log location; restore is unsafe.', { trashId: entry.trashId })
278
298
  }
279
299
 
280
300
  const steps = []
281
- if (entry.mode === 'hard' && hasLog) {
301
+ if (entry.mode === 'hard' && logInTrash) {
282
302
  steps.push({
283
303
  name: 'messages',
284
304
  run: async () => {
285
- const undo = await ops.sessionLog.restoreEntry(entry.sessionLog)
305
+ const undo = await ops.sessionLog.restoreEntry(sessionLog)
286
306
  return async () => {
287
307
  await undo()
288
308
  }
@@ -314,10 +334,15 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
314
334
  })
315
335
  }
316
336
  if (steps.length === 0) {
317
- throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has nothing left to restore; discard it to reclaim the space.', {
318
- trashId: entry.trashId,
319
- status: previousStatus,
320
- })
337
+ // The log is already back on disk (an interrupted purge can leave the
338
+ // manifest pointing at a log that was moved back): clearing the entry is
339
+ // the only remaining action, and it is safe.
340
+ if (!(entry.mode === 'hard' && logInPlace)) {
341
+ throw new DeleteError('E_TRASH_CONFLICT', 'The recycle-bin entry has nothing left to restore; discard it to reclaim the space.', {
342
+ trashId: entry.trashId,
343
+ status: previousStatus,
344
+ })
345
+ }
321
346
  }
322
347
 
323
348
  const startedAt = now()
@@ -340,7 +365,7 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
340
365
  requestId,
341
366
  failedStep: result.failedStep,
342
367
  rollback: { ok: result.rolledBack, errors: result.rollbackErrors.map((item) => ({ step: item.step, message: String(item.error) })) },
343
- source: 'web-ui',
368
+ source,
344
369
  })
345
370
  .catch(() => {})
346
371
  throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
@@ -356,7 +381,7 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
356
381
  actor,
357
382
  requestId,
358
383
  durationMs: now() - startedAt,
359
- source: 'web-ui',
384
+ source,
360
385
  })
361
386
  await trash.remove(entry.trashId).catch(() => {})
362
387
  return { trashId: entry.trashId, sessionId: entry.sessionId }
@@ -379,7 +404,7 @@ export async function performRestore({ entry, ops, trash, audit, now = Date.now,
379
404
  * @param {string} [input.actor]
380
405
  * @param {string} [input.requestId]
381
406
  */
382
- export async function performPurge({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', requestId = null }) {
407
+ export async function performPurge({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
383
408
  if (!['trashed', 'restored'].includes(entry.status)) {
384
409
  throw new DeleteError('E_TRASH_CONFLICT', 'This recycle-bin entry cannot be purged in its current state.', { trashId: entry.trashId, status: entry.status })
385
410
  }
@@ -405,8 +430,17 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
405
430
  name: 'messages',
406
431
  run: async () => {
407
432
  const moved = await ops.sessionLog.moveToTrash(session, trash.entryDir(entry.trashId))
433
+ try {
434
+ await trash.update(entry.trashId, { sessionLog: moved })
435
+ } catch (error) {
436
+ await ops.sessionLog.restoreEntry(moved).catch(() => {})
437
+ throw error
438
+ }
439
+ entry.sessionLog = moved
408
440
  return async () => {
409
441
  await ops.sessionLog.restoreEntry(moved)
442
+ entry.sessionLog = null
443
+ await trash.update(entry.trashId, { sessionLog: null }).catch(() => {})
410
444
  }
411
445
  },
412
446
  })
@@ -445,7 +479,7 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
445
479
  requestId,
446
480
  failedStep: result.failedStep,
447
481
  rollback: { ok: result.rolledBack, errors: result.rollbackErrors.map((item) => ({ step: item.step, message: String(item.error) })) },
448
- source: 'web-ui',
482
+ source,
449
483
  })
450
484
  .catch(() => {})
451
485
  throw toDeleteError(result, { sessionId: entry.sessionId, title: entry.title })
@@ -461,7 +495,7 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
461
495
  actor,
462
496
  requestId,
463
497
  durationMs: now() - startedAt,
464
- source: 'web-ui',
498
+ source,
465
499
  })
466
500
  return { trashId: entry.trashId, sessionId: entry.sessionId }
467
501
  }
@@ -484,7 +518,7 @@ export async function performPurge({ entry, ops, trash, audit, now = Date.now, a
484
518
  * @param {string} [input.actor]
485
519
  * @param {string} [input.requestId]
486
520
  */
487
- export async function performDiscard({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', requestId = null }) {
521
+ export async function performDiscard({ entry, ops, trash, audit, now = Date.now, actor = 'local-user', source = 'web-ui', requestId = null }) {
488
522
  if (entry.status === 'trashed') {
489
523
  throw new DeleteError('E_TRASH_CONFLICT', 'This entry is a committed deletion; restore or purge it instead of discarding it.', {
490
524
  trashId: entry.trashId,
@@ -524,7 +558,7 @@ export async function performDiscard({ entry, ops, trash, audit, now = Date.now,
524
558
  actor,
525
559
  requestId,
526
560
  durationMs: now() - startedAt,
527
- source: 'web-ui',
561
+ source,
528
562
  })
529
563
  .catch(() => {})
530
564
  return { trashId: entry.trashId, sessionId: entry.sessionId }
@@ -92,7 +92,10 @@ function readBool(raw, fallback, field, warnings) {
92
92
  }
93
93
 
94
94
  function readString(raw, fallback, field, warnings) {
95
- if (raw === undefined) return fallback
95
+ // `null` is the documented "unset" spelling for nullable fields
96
+ // (`memory.lingshu.rootDir`, `memory.generic.indexFile`), so it must not be
97
+ // reported as a type error on every activation.
98
+ if (raw === undefined || raw === null) return fallback
96
99
  if (typeof raw === 'string') return raw.trim()
97
100
  warnings.push(`${field}: expected a string, using ${fallback}`)
98
101
  return fallback
@@ -5,12 +5,14 @@
5
5
  * maps `code` to an HTTP status once, so the UI can branch on the code without
6
6
  * parsing prose. Codes are stable API: add new ones, never rename existing ones.
7
7
  */
8
+ import { redactDetails } from './redact.js'
8
9
 
9
10
  /** HTTP status per error code. Keep this table exhaustive for every thrown code. */
10
11
  export const ERROR_STATUS = Object.freeze({
11
12
  E_DISABLED: 503,
12
13
  E_FORBIDDEN_ORIGIN: 403,
13
14
  E_FORBIDDEN_HOST: 403,
15
+ E_UNAUTHORIZED: 401,
14
16
  E_BAD_REQUEST: 400,
15
17
  E_NOT_FOUND: 404,
16
18
  E_POLICY_PROTECTED: 403,
@@ -29,6 +31,7 @@ export const ERROR_STATUS = Object.freeze({
29
31
  E_TRASH_NOT_FOUND: 404,
30
32
  E_TRASH_CONFLICT: 409,
31
33
  E_MEMORY_FAILED: 500,
34
+ E_LOCK_BUSY: 409,
32
35
  E_INTERNAL: 500,
33
36
  })
34
37
 
@@ -69,10 +72,10 @@ export function asDeleteError(error, fallbackCode = 'E_INTERNAL') {
69
72
  }, { cause: error })
70
73
  }
71
74
 
72
- /** Wire shape for a failed call. */
75
+ /** Wire shape for a failed call. Host paths are redacted before crossing HTTP. */
73
76
  export function toWireError(error) {
74
77
  const normalized = asDeleteError(error)
75
- return { ok: false, error: normalized.toJSON() }
78
+ return { ok: false, error: redactDetails(normalized.toJSON()) }
76
79
  }
77
80
 
78
81
  /** Wire shape for a successful call. */
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Small cross-process advisory lock built from an exclusively-created file.
3
+ *
4
+ * This is NOT a replacement for an OS byte-range lock: it coordinates writers
5
+ * that use the same protocol (this plugin's Host processes and its memory
6
+ * adapter). It deliberately never touches another program's lock file — the
7
+ * 灵枢 (md_cg) store keeps a permanent, zero-byte `<index>.lock` that is
8
+ * guarded by `msvcrt.locking`/`flock`; treating that file's existence as a
9
+ * lock (or deleting it) breaks its writer instead of excluding it.
10
+ *
11
+ * Semantics:
12
+ * - acquisition creates `<lockPath>` with `wx`, writes `{pid, nonce, at}`;
13
+ * - a holder refreshes the file mtime on a heartbeat, so a long operation is
14
+ * never mistaken for a crashed one;
15
+ * - a lock is stale when its mtime is older than `staleMs`, or when the
16
+ * recorded pid is provably gone; a stale lock is removed and retried;
17
+ * - release only removes the file when it still carries this owner's nonce,
18
+ * so a lock broken by a peer cannot make us delete the peer's lock.
19
+ *
20
+ * Every wait sleeps, and the wait budget is checked on every iteration: a
21
+ * held lock can never spin forever (the bug this module replaces).
22
+ */
23
+ import * as defaultFs from 'node:fs/promises'
24
+ import { randomBytes } from 'node:crypto'
25
+
26
+ const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
27
+
28
+ function defaultIsProcessAlive(pid) {
29
+ if (!Number.isInteger(pid) || pid <= 0) return false
30
+ try {
31
+ process.kill(pid, 0)
32
+ return true
33
+ } catch (error) {
34
+ // EPERM means the process exists but is owned by someone else.
35
+ return error?.code === 'EPERM'
36
+ }
37
+ }
38
+
39
+ function parseOwner(raw) {
40
+ try {
41
+ const value = JSON.parse(String(raw))
42
+ if (value !== null && typeof value === 'object') {
43
+ return { pid: Number.isInteger(value.pid) ? value.pid : null, nonce: typeof value.nonce === 'string' ? value.nonce : null }
44
+ }
45
+ } catch {
46
+ // A legacy/foreign lock may be empty or plain text; it has no owner.
47
+ }
48
+ return { pid: null, nonce: null }
49
+ }
50
+
51
+ /**
52
+ * @param {object} options
53
+ * @param {string} options.lockPath absolute path of the lock file
54
+ * @param {number} [options.timeoutMs] wait budget for one acquisition
55
+ * @param {number} [options.staleMs] age after which an ownerless lock is broken
56
+ * @param {number} [options.heartbeatMs] mtime refresh interval while held
57
+ * @param {number} [options.retryMs] poll interval
58
+ * @param {() => number} [options.now]
59
+ * @param {(ms: number) => Promise<void>} [options.sleep]
60
+ * @param {(pid: number) => boolean} [options.isProcessAlive]
61
+ * @param {object} [options.fsApi]
62
+ * @param {number} [options.pid]
63
+ */
64
+ export function createFileLock({
65
+ lockPath,
66
+ timeoutMs = 15_000,
67
+ staleMs = 120_000,
68
+ heartbeatMs = 5_000,
69
+ retryMs = 50,
70
+ now = Date.now,
71
+ sleep = sleepDefault,
72
+ isProcessAlive = defaultIsProcessAlive,
73
+ fsApi = defaultFs,
74
+ pid = process.pid,
75
+ }) {
76
+ if (typeof lockPath !== 'string' || lockPath === '') throw new Error('file-lock: lockPath is required')
77
+
78
+ /**
79
+ * Acquire the lock.
80
+ * @returns {Promise<() => Promise<void>>} release
81
+ */
82
+ async function acquire() {
83
+ const nonce = randomBytes(8).toString('hex')
84
+ const started = now()
85
+ let handle = null
86
+
87
+ while (handle === null) {
88
+ try {
89
+ handle = await fsApi.open(lockPath, 'wx')
90
+ } catch (error) {
91
+ if (error?.code !== 'EEXIST') throw error
92
+ let info
93
+ try {
94
+ info = await fsApi.stat(lockPath)
95
+ } catch (statError) {
96
+ if (statError?.code === 'ENOENT') continue
97
+ throw statError
98
+ }
99
+ const age = Math.max(0, now() - info.mtimeMs)
100
+ let owner = { pid: null, nonce: null }
101
+ try {
102
+ owner = parseOwner(await fsApi.readFile(lockPath, 'utf8'))
103
+ } catch {
104
+ // Unreadable owner text is treated as ownerless, not as a crash.
105
+ }
106
+ const deadOwner = owner.pid !== null && !isProcessAlive(owner.pid)
107
+ if (age > staleMs || deadOwner) {
108
+ await fsApi.rm(lockPath, { force: true }).catch(() => {})
109
+ continue
110
+ }
111
+ if (now() - started >= timeoutMs) {
112
+ const busy = new Error(`The lock ${lockPath} is held by another process.`)
113
+ busy.code = 'E_LOCK_BUSY'
114
+ throw busy
115
+ }
116
+ await sleep(retryMs)
117
+ continue
118
+ }
119
+
120
+ try {
121
+ await handle.writeFile(`${JSON.stringify({ pid, nonce, at: now() })}\n`, 'utf8')
122
+ } catch (error) {
123
+ await handle.close().catch(() => {})
124
+ await fsApi.rm(lockPath, { force: true }).catch(() => {})
125
+ throw error
126
+ }
127
+ break
128
+ }
129
+
130
+ const heartbeat = setInterval(() => {
131
+ const stamp = new Date()
132
+ fsApi.utimes(lockPath, stamp, stamp).catch(() => {})
133
+ }, Math.max(250, heartbeatMs))
134
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
135
+
136
+ let released = false
137
+ return async () => {
138
+ if (released) return
139
+ released = true
140
+ clearInterval(heartbeat)
141
+ try {
142
+ const owner = parseOwner(await fsApi.readFile(lockPath, 'utf8').catch(() => ''))
143
+ if (owner.nonce === nonce) await fsApi.rm(lockPath, { force: true }).catch(() => {})
144
+ } finally {
145
+ await handle?.close().catch(() => {})
146
+ }
147
+ }
148
+ }
149
+
150
+ return { acquire, lockPath }
151
+ }
@@ -17,13 +17,13 @@ export function createGenericJsonAdapter({ indexFile, rootDir, sessionField = 's
17
17
  if (typeof indexFile !== 'string' || indexFile === '') return null
18
18
  return createJsonIndexMemoryAdapter({
19
19
  id: 'generic-json',
20
- label: 'Generic JSON memory store',
20
+ // Neutral label: the Client localizes by adapter id.
21
+ label: 'generic-json',
21
22
  indexFile,
22
23
  rootDir: typeof rootDir === 'string' && rootDir !== '' ? rootDir : null,
23
24
  sessionField,
24
25
  nodesPointer,
25
26
  contentField: rootDir === null ? null : 'path',
26
- useStoreLock: false,
27
27
  now,
28
28
  fsApi,
29
29
  })