dsh-conversation-recycle-bin 0.3.11 → 0.3.13

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/lib/host/api.js CHANGED
@@ -55,6 +55,7 @@ import { createCatalogOps, createSessionLogOps } from './catalog.js'
55
55
  import { createLifecycleOps } from './lifecycle.js'
56
56
  import { createSessionGateway } from './gateway.js'
57
57
  import { createMemoryAdapters } from './memory.js'
58
+ import { encodeSessionSegment, projectKey, readSessionLogCwd } from './paths.js'
58
59
  import { createFeedbackService } from './feedback.js'
59
60
  import { PLUGIN_VERSION } from '../version.js'
60
61
  import { SESSION_ID_PATTERN } from '../core/ids.js'
@@ -75,6 +76,21 @@ const MAX_BODY_BYTES = 256 * 1024
75
76
  */
76
77
  export const TRASH_LOCK_BUDGET_MS = 180_000
77
78
 
79
+ /**
80
+ * Slack added on top of an operation's lock budget before a queued request is
81
+ * rejected. The Host publishes the resulting number (`queueTimeoutMs`) so the
82
+ * Client waits for the Host's answer instead of inventing a shorter deadline of
83
+ * its own — a client that gave up 60 s early used to report a timeout for an
84
+ * operation the Host then completed.
85
+ */
86
+ export const QUEUE_GRACE_MS = 60_000
87
+
88
+ /** The wait a caller should allow for `lockBudgetMs` plus queueing behind it. */
89
+ export function queueBudgetFor(lockBudgetMs) {
90
+ const budget = Number(lockBudgetMs)
91
+ return (Number.isFinite(budget) && budget > 0 ? budget : 30_000) + QUEUE_GRACE_MS
92
+ }
93
+
78
94
  function isLoopbackHostname(hostname) {
79
95
  const value = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '')
80
96
  return value === 'localhost' || value === '::1' || value === '0:0:0:0:0:0:0:1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)
@@ -397,6 +413,25 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
397
413
  return false
398
414
  }
399
415
  },
416
+ /**
417
+ * Reconstruct the original session-log location of an entry whose move
418
+ * completed but whose manifest did not record it (a crash between
419
+ * `moveDirectory` and `trash.update`). The log header still carries the
420
+ * session's cwd, which is the only durable input needed to rebuild the
421
+ * Host's project-directory path; a missing/unreadable header returns null
422
+ * and the caller keeps refusing instead of guessing.
423
+ */
424
+ recoverSessionLog: async (entry) => {
425
+ if (typeof entry?.sessionId !== 'string' || !SESSION_ID_PATTERN.test(entry.sessionId)) return null
426
+ if (typeof entry?.trashId !== 'string' || !isTrashId(entry.trashId)) return null
427
+ const sessionDir = join(trash.entryDir(entry.trashId), 'session')
428
+ if (!(await readdir(sessionDir).catch(() => null))) return null
429
+ const cwd = await readSessionLogCwd(sessionDir)
430
+ if (cwd === null) return null
431
+ const from = join(paths.sessionsRoot, projectKey(cwd), encodeSessionSegment(entry.sessionId))
432
+ if (!isPathInside(paths.sessionsRoot, from) || !isPathInside(paths.trashDir, sessionDir)) return null
433
+ return { sessionId: entry.sessionId, from, to: sessionDir }
434
+ },
400
435
  // Manifest paths are data, not truth: a hand-edited manifest must not turn
401
436
  // "restore" into a proxy for stat/move on arbitrary directories.
402
437
  isInsideSessionsRoot: (target) => typeof target === 'string' && target !== '' && isPathInside(paths.sessionsRoot, target),
@@ -451,9 +486,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
451
486
  * finishes, and never waits forever either.
452
487
  */
453
488
  function serialize(operation, { lockTimeoutMs, shouldAbort } = {}) {
454
- const graceMs = 60_000
455
- const queueBudgetMs = lockTimeoutMs === undefined ? 30_000 + graceMs : lockTimeoutMs + graceMs
489
+ const queueBudgetMs = queueBudgetFor(lockTimeoutMs === undefined ? 30_000 : lockTimeoutMs)
456
490
  let abandoned = false
491
+ // Once the operation itself starts, the queue timer stops: from that moment
492
+ // the outcome belongs to the operation, and answering "not applied" would be
493
+ // a lie (the purge/restore may well have happened). The budget therefore
494
+ // covers the wait, which is the only part this function can honestly abort.
495
+ let started = false
496
+ let timer = null
457
497
  const aborted = () => typeof shouldAbort === 'function' && shouldAbort() === true
458
498
  const next = mutationChain.then(async () => {
459
499
  if (abandoned) {
@@ -479,6 +519,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
479
519
  if (aborted()) {
480
520
  throw new DeleteError('E_ABANDONED', 'The client disconnected before this request ran; nothing was changed.', {})
481
521
  }
522
+ started = true
523
+ if (timer !== null) clearTimeout(timer)
482
524
  return await operation()
483
525
  } finally {
484
526
  await release()
@@ -486,7 +528,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
486
528
  })
487
529
  mutationChain = next.then(() => undefined, () => undefined)
488
530
  return new Promise((resolve, reject) => {
489
- const timer = setTimeout(() => {
531
+ timer = setTimeout(() => {
532
+ if (started) return
490
533
  abandoned = true
491
534
  reject(
492
535
  new DeleteError(
@@ -688,6 +731,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
688
731
  trashRetentionDays: config.trashRetentionDays,
689
732
  confirmTokenTtlMs: config.confirmTokenTtlMs,
690
733
  planTimeoutMs,
734
+ // How long the Client should wait for the answer: the plan's own lock
735
+ // budget plus the queueing grace. The Client used to derive a shorter
736
+ // number and give up 60 s before the Host finished.
737
+ queueTimeoutMs: queueBudgetFor(planTimeoutMs),
691
738
  },
692
739
  }
693
740
  }
@@ -782,7 +829,6 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
782
829
  failures.push({ sessionId: session.id, error: rateError.toJSON() })
783
830
  continue
784
831
  }
785
- if (!validated.record.used) markConfirmationUsed(validated)
786
832
  try {
787
833
  const outcome = await performSessionDelete({
788
834
  session,
@@ -817,6 +863,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
817
863
  gateway.invalidate()
818
864
  invalidateTrashCache()
819
865
 
866
+ // The token is burned only once the whole plan went through. It used to be
867
+ // marked used before the first item, so a batch that failed halfway left the
868
+ // user holding a dead token: the remaining sessions could not be retried
869
+ // without previewing again and retyping the confirmation phrase. The token
870
+ // is bound to (ids, mode, includeMemory), so a retry can only replay this
871
+ // same plan — already-deleted sessions answer E_NOT_FOUND, never a second
872
+ // deletion.
873
+ if (failures.length === 0) markConfirmationUsed(validated)
820
874
  if (results.length === 0 && failures.length > 0) {
821
875
  const first = failures[0].error
822
876
  throw new DeleteError(first.code, first.message, { ...(first.details ?? {}), failures })
@@ -902,6 +956,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
902
956
  return {
903
957
  enabled: config.enabled,
904
958
  version: PLUGIN_VERSION,
959
+ // The Client cannot tell a broken deployment from a broken permission
960
+ // otherwise: with `requireConnection` on and no service mounted, every
961
+ // route answers 401 and the panel just looked empty.
962
+ connectionFence: connectionFencePresent(),
905
963
  defaultMode: config.defaultMode,
906
964
  memory: {
907
965
  enabled: config.memory.enabled,
@@ -915,6 +973,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
915
973
  rate: limiter.status(),
916
974
  trashRetentionDays: config.trashRetentionDays,
917
975
  confirmTokenTtlMs: config.confirmTokenTtlMs,
976
+ // Trash mutations take the fixed trash budget; the Client waits for it
977
+ // instead of using its generic request timeout.
978
+ queueTimeoutMs: queueBudgetFor(TRASH_LOCK_BUDGET_MS),
918
979
  },
919
980
  ui: config.ui,
920
981
  feedback: feedbackService.status(),
@@ -953,13 +1014,45 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
953
1014
  })
954
1015
  }
955
1016
 
956
- function serializeEntry(entry) {
1017
+ /** Ordered map with at most `limit` filesystem probes in flight. */
1018
+ async function mapLimit(items, limit, fn) {
1019
+ const out = new Array(items.length)
1020
+ let next = 0
1021
+ const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
1022
+ while (true) {
1023
+ const index = next
1024
+ next += 1
1025
+ if (index >= items.length) return
1026
+ out[index] = await fn(items[index], index)
1027
+ }
1028
+ })
1029
+ await Promise.all(workers)
1030
+ return out
1031
+ }
1032
+
1033
+ /** Disk payload of one entry, for the capability flags the Client renders. */
1034
+ async function entryDisk(trashId) {
1035
+ if (!isTrashId(trashId)) return { hasLogFiles: false, hasMemoryFiles: false }
1036
+ let dir
1037
+ try {
1038
+ dir = trash.entryDir(trashId)
1039
+ } catch {
1040
+ return { hasLogFiles: false, hasMemoryFiles: false }
1041
+ }
1042
+ return {
1043
+ hasLogFiles: await ops.pathExists(join(dir, 'session')),
1044
+ hasMemoryFiles: await ops.hasFiles(join(dir, 'memory')),
1045
+ }
1046
+ }
1047
+
1048
+ async function serializeEntry(entry) {
957
1049
  const retentionDays = config.trashRetentionDays
958
1050
  // The Client must not recompute the window from `deletedAt`: a browser clock
959
1051
  // that is a day off would shift the promise the Host actually keeps. The
960
1052
  // countdown is therefore decided here, once, by the same predicate the sweep
961
1053
  // uses (`daysUntilExpiry`), and `expiresAt` carries the absolute deadline.
962
1054
  const daysLeft = daysUntilExpiry(entry, { retentionDays, now: now() })
1055
+ const disk = await entryDisk(entry.trashId)
963
1056
  return {
964
1057
  trashId: entry.trashId,
965
1058
  sessionId: entry.sessionId,
@@ -974,13 +1067,38 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
974
1067
  memoryAdapters: Array.isArray(entry.memory?.adapters) ? entry.memory.adapters : [],
975
1068
  hasSessionLog: entry.sessionLog !== null && entry.sessionLog !== undefined,
976
1069
  status: entry.status,
977
- // The UI renders buttons from the same predicate the routes enforce, so a
978
- // row can no longer offer an action that will be refused.
979
- capabilities: entryCapabilities(entry),
1070
+ // The UI renders buttons from the same predicate the routes enforce. Disk
1071
+ // payload is part of that predicate: a manifest can be empty while the
1072
+ // moved log/memory copy is still on disk, and a status-only answer
1073
+ // advertised actions the routes refused.
1074
+ capabilities: entryCapabilities(entry, { ...disk, requirePayload: true }),
980
1075
  error: entry.error ?? null,
981
1076
  }
982
1077
  }
983
1078
 
1079
+ /** A folder whose manifest cannot be read still has a reliable disk payload. */
1080
+ async function serializeUnreadable(item) {
1081
+ const disk = await entryDisk(item.trashId)
1082
+ return {
1083
+ trashId: item.trashId,
1084
+ sessionId: null,
1085
+ title: null,
1086
+ mode: null,
1087
+ includeMemory: false,
1088
+ deletedAt: 0,
1089
+ updatedAt: 0,
1090
+ memory: 0,
1091
+ memoryAdapters: [],
1092
+ hasSessionLog: false,
1093
+ status: 'unreadable',
1094
+ capabilities: entryCapabilities({ status: 'unreadable' }, disk),
1095
+ error: {
1096
+ code: item.reason,
1097
+ message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
1098
+ },
1099
+ }
1100
+ }
1101
+
984
1102
  async function trashList() {
985
1103
  // The retention sweep is kicked off, not awaited: it needs the mutation lock
986
1104
  // (a purge in flight can hold it for minutes), and a GET must never block on
@@ -994,28 +1112,16 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
994
1112
  // is missing/corrupt are surfaced as `unreadable` so they are not stranded
995
1113
  // on disk; the Client renders both groups separately.
996
1114
  const { entries: all, unreadable } = await trash.scanAll()
1115
+ const trashed = all.filter((entry) => entry.status === 'trashed')
1116
+ const incompleteEntries = all.filter((entry) => entry.status !== 'trashed')
1117
+ const [entries, incomplete, unreadableRows] = await Promise.all([
1118
+ mapLimit(trashed, 8, serializeEntry),
1119
+ mapLimit(incompleteEntries, 8, serializeEntry),
1120
+ mapLimit(unreadable, 8, serializeUnreadable),
1121
+ ])
997
1122
  const result = {
998
- entries: all.filter((entry) => entry.status === 'trashed').map(serializeEntry),
999
- incomplete: [
1000
- ...all.filter((entry) => entry.status !== 'trashed').map(serializeEntry),
1001
- ...unreadable.map((item) => ({
1002
- trashId: item.trashId,
1003
- sessionId: null,
1004
- title: null,
1005
- mode: null,
1006
- includeMemory: false,
1007
- deletedAt: 0,
1008
- updatedAt: 0,
1009
- memory: 0,
1010
- memoryAdapters: [],
1011
- hasSessionLog: false,
1012
- status: 'unreadable',
1013
- error: {
1014
- code: item.reason,
1015
- message: 'The manifest could not be read; discard this folder only after confirming it holds no log or memory files.',
1016
- },
1017
- })),
1018
- ],
1123
+ entries,
1124
+ incomplete: [...incomplete, ...unreadableRows],
1019
1125
  // What the background sweep did last time, so entries that vanished can be
1020
1126
  // explained instead of looking like a bug. `null` until the first sweep.
1021
1127
  lastSweep: lastRetentionResult,
@@ -1069,11 +1175,15 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1069
1175
  for (const candidate of expired) {
1070
1176
  const entry = await trash.read(candidate.trashId)
1071
1177
  if (entry === null || !entryCapabilities(entry).canPurge) continue
1072
- // A `trashed` soft entry whose conversation is resolvable again (the
1073
- // user re-created it, or unarchived it outside this plugin) must not be
1074
- // purged unattended: `performPurge` would quiesce and hard-delete the
1075
- // conversation the user is working in. Retention is a janitor, not a
1076
- // killer — the entry stays for the next run and for the user.
1178
+ // A soft entry's log stays on disk, so "the session can still be
1179
+ // inspected" is its NORMAL state and must not block retention. Only a
1180
+ // conversation the user actually revived (unarchived) or one that is
1181
+ // running is left alone: purging it unattended would quiesce and
1182
+ // hard-delete the conversation the user is working in. Retention is a
1183
+ // janitor, not a killer.
1184
+ //
1185
+ // `archived` unknown (older Host shape, unreadable workspace state)
1186
+ // stays conservative: skip and let the user decide.
1077
1187
  if (entry.mode === 'soft' && typeof entry.sessionId === 'string' && entry.sessionId !== '') {
1078
1188
  let session = null
1079
1189
  try {
@@ -1084,8 +1194,12 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1084
1194
  session = { unreadable: true }
1085
1195
  }
1086
1196
  if (session !== null && session !== undefined) {
1087
- failed.push({ trashId: candidate.trashId, code: 'E_SESSION_ALIVE' })
1088
- continue
1197
+ const revived = session.archived === false
1198
+ const running = session.running === true || session.live === true || session.unreadable === true
1199
+ if (revived || running || session.archived !== true) {
1200
+ failed.push({ trashId: candidate.trashId, code: 'E_SESSION_ALIVE' })
1201
+ continue
1202
+ }
1089
1203
  }
1090
1204
  }
1091
1205
  try {
@@ -1161,6 +1275,23 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1161
1275
  }
1162
1276
  }
1163
1277
 
1278
+ /**
1279
+ * True when a real `connection` service is mounted. Probed rather than
1280
+ * injected: a composition without it must still load.
1281
+ */
1282
+ function connectionFencePresent() {
1283
+ const connection = connectionService()
1284
+ return connection !== undefined && typeof connection.requestRejection === 'function'
1285
+ }
1286
+
1287
+ /** `/state` (or the bare prefix) as a GET — the only route readable without a connection fence. */
1288
+ function isStateProbe(req) {
1289
+ if (String(req.method ?? 'GET').toUpperCase() !== 'GET') return false
1290
+ const pathname = new URL(req.url ?? '/', 'http://host.invalid').pathname
1291
+ const path = pathname.startsWith(ROUTE_PREFIX) ? pathname.slice(ROUTE_PREFIX.length) : pathname
1292
+ return path === '' || path === '/state'
1293
+ }
1294
+
1164
1295
  /** One request → one response. Route matching stays here, not in `apply`. */
1165
1296
  const handleRequest = async (req, res) => {
1166
1297
  const method = String(req.method ?? 'GET').toUpperCase()
@@ -1298,8 +1429,22 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1298
1429
  const handler = (req, res) => {
1299
1430
  void (async () => {
1300
1431
  const connection = connectionService()
1301
- const hasConnectionFence = connection !== undefined && typeof connection.requestRejection === 'function'
1432
+ const hasConnectionFence = connectionFencePresent()
1302
1433
  if (!hasConnectionFence && config.requireConnection !== false) {
1434
+ // `/state` stays readable: it carries no conversation data (limits,
1435
+ // adapters, switches) and it is how the panel explains why everything
1436
+ // else is refused. Every other route — including the session list and
1437
+ // the trash — stays 401, because the remaining fence cannot tell one
1438
+ // local process from another.
1439
+ if (isStateProbe(req)) {
1440
+ const origin = transportVerdict(req)
1441
+ if (!origin.ok) {
1442
+ sendJson(res, origin.error.httpStatus, toWireError(origin.error))
1443
+ return
1444
+ }
1445
+ sendJson(res, 200, { ok: true, state: await state() })
1446
+ return
1447
+ }
1303
1448
  logger.error?.('[conversation-recycle-bin] no connection service; refusing request because requireConnection is enabled')
1304
1449
  sendJson(res, 401, toWireError(new DeleteError('E_UNAUTHORIZED', 'This Host has no connection layer to authenticate the request; set requireConnection: false to allow it.', {})))
1305
1450
  return
@@ -96,21 +96,28 @@ export function createSessionLogOps({ gateway, paths, config, now = Date.now, de
96
96
  return {
97
97
  /**
98
98
  * Move `<sessions>/<slug>/<sessionId>/` into the recycle-bin entry.
99
+ *
100
+ * `beforeMove` receives the `{ from, to }` mapping after every check has
101
+ * passed but before the rename. The caller persists that mapping first, so
102
+ * a crash between the move and the later manifest write cannot leave a log
103
+ * in `session/` that the manifest no longer knows how to put back.
99
104
  * @returns {Promise<{ sessionId: string, from: string, to: string }>}
100
105
  */
101
- async moveToTrash(session, entryDir) {
106
+ async moveToTrash(session, entryDir, { beforeMove } = {}) {
102
107
  const from = await gateway.findSessionDir(session.id)
103
108
  if (from === null) {
104
109
  throw new DeleteError('E_NOT_FOUND', 'The conversation log was not found on disk.', { sessionId: session.id })
105
110
  }
106
111
  await assertNoForeignWriter(session, from)
107
112
  const to = join(entryDir, 'session')
113
+ const moved = { sessionId: session.id, from, to }
114
+ if (typeof beforeMove === 'function') await beforeMove(moved)
108
115
  await moveDirectory(from, to)
109
116
  // Derived cache only: safe to lose, and removing it here prevents a stale
110
117
  // projection row for a session that no longer exists. Best-effort by
111
118
  // design — the cache self-heals, so this must not fail the transaction.
112
119
  if (paths !== undefined) await removeProjectionCheckpoint(paths.storagesDir, session.id)
113
- return { sessionId: session.id, from, to }
120
+ return moved
114
121
  },
115
122
 
116
123
  async restoreEntry(moved) {