@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.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/lib/state.js ADDED
@@ -0,0 +1,155 @@
1
+ import fs from 'node:fs/promises'
2
+ import { memberList } from './members.js'
3
+
4
+ /** Maximum concurrent filesystem probes while describing entries. */
5
+ const PROBE_CONCURRENCY = 3
6
+
7
+ /**
8
+ * @param {readonly T[]} items input items.
9
+ * @param {number} limit maximum in-flight operations.
10
+ * @param {(item: T) => Promise<R>} worker per-item operation.
11
+ * @returns {Promise<R[]>} results in input order.
12
+ * @template T, R
13
+ */
14
+ async function mapLimit(items, limit, worker) {
15
+ const results = new Array(items.length)
16
+ let next = 0
17
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
18
+ while (next < items.length) {
19
+ const index = next
20
+ next += 1
21
+ results[index] = await worker(items[index])
22
+ }
23
+ })
24
+ await Promise.all(runners)
25
+ return results
26
+ }
27
+
28
+ /**
29
+ * @param {string | null | undefined} directory candidate workspace directory.
30
+ * @returns {Promise<boolean>} whether the directory exists right now.
31
+ */
32
+ async function directoryExists(directory) {
33
+ if (typeof directory !== 'string' || directory === '') return false
34
+ try {
35
+ const stats = await fs.stat(directory)
36
+ return stats.isDirectory()
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Build the read-only `/state` projection from the committed index.
44
+ *
45
+ * @param {{
46
+ * store: import('./store.js').TrashStore,
47
+ * config: { retentionDays: number, sweepIntervalHours: number, enableAutoCleanup: boolean },
48
+ * trashRootDisplay: string,
49
+ * nextSweepAt?: () => number | null,
50
+ * cleanup?: () => object,
51
+ * }} deps state inputs.
52
+ * @returns {{ snapshot: () => Promise<object>, describe: (entry: object) => Promise<object> }} the state service.
53
+ */
54
+ export function createStateService({ store, config, trashRootDisplay, nextSweepAt = () => null, cleanup }) {
55
+ /**
56
+ * @returns {object} cleanup diagnostics, or a neutral fallback.
57
+ */
58
+ function cleanupDiagnostics() {
59
+ if (typeof cleanup === 'function') {
60
+ try {
61
+ const value = cleanup()
62
+ if (value !== null && typeof value === 'object') return value
63
+ } catch {
64
+ // Diagnostics must never break the read-only state projection.
65
+ }
66
+ }
67
+ return {
68
+ enabled: config.enableAutoCleanup,
69
+ timerBacked: false,
70
+ lastAttemptAt: null,
71
+ lastSuccessAt: null,
72
+ nextSweepAt: nextSweepAt(),
73
+ retryAt: null,
74
+ consecutiveFailures: 0,
75
+ lastRun: null,
76
+ }
77
+ }
78
+
79
+ /**
80
+ * @param {object} entry committed index entry.
81
+ * @returns {Promise<object>} the wire entry.
82
+ */
83
+ async function describeEntry(entry) {
84
+ const cwd = typeof entry.originalCwd === 'string' && entry.originalCwd !== '' ? entry.originalCwd : null
85
+ const workspaceNowExists = cwd === null ? null : await directoryExists(cwd)
86
+ const expiresAt =
87
+ typeof entry.trashedAt === 'number' && entry.trashedAt > 0 ? entry.trashedAt + config.retentionDays * 86_400_000 : null
88
+ const trashed = entry.state === 'trashed'
89
+ const members = memberList(entry)
90
+ return {
91
+ sessionId: entry.sessionId,
92
+ title: entry.title,
93
+ trashedAt: entry.trashedAt,
94
+ expiresAt,
95
+ originalCwd: cwd,
96
+ workspaceExistsAtDeletion:
97
+ typeof entry.workspaceExistsAtDeletion === 'boolean' ? entry.workspaceExistsAtDeletion : null,
98
+ workspaceNowExists,
99
+ sizeBytes: typeof entry.sizeBytes === 'number' ? entry.sizeBytes : null,
100
+ fileCount: typeof entry.fileCount === 'number' ? entry.fileCount : null,
101
+ formatStatus: entry.formatStatus ?? null,
102
+ state: entry.state,
103
+ canRestore: trashed,
104
+ canPurge: trashed,
105
+ canForget: entry.state === 'missing',
106
+ requiresOrphanRestore: cwd !== null && workspaceNowExists === false,
107
+ lastError: entry.lastError ?? null,
108
+ subagentCount: Math.max(0, members.length - 1),
109
+ members: members.map((member) => ({
110
+ sessionId: member.sessionId,
111
+ origin: member.origin === 'subagent' ? 'subagent' : null,
112
+ sizeBytes: typeof member.sizeBytes === 'number' ? member.sizeBytes : null,
113
+ })),
114
+ }
115
+ }
116
+
117
+ return {
118
+ /**
119
+ * @param {object} entry committed index entry.
120
+ * @returns {Promise<object>} the wire entry.
121
+ */
122
+ describe: describeEntry,
123
+ /**
124
+ * @returns {Promise<object>} the complete `/state` response body.
125
+ */
126
+ async snapshot() {
127
+ const entries = await mapLimit(store.entries(), PROBE_CONCURRENCY, (entry) => describeEntry(entry))
128
+ let totalBytes = 0
129
+ let failedEntries = 0
130
+ for (const entry of entries) {
131
+ if (typeof entry.sizeBytes === 'number') totalBytes += entry.sizeBytes
132
+ if (typeof entry.lastError === 'string' && entry.lastError !== '') failedEntries += 1
133
+ }
134
+ const diagnostics = cleanupDiagnostics()
135
+ return {
136
+ version: 1,
137
+ config: {
138
+ retentionDays: config.retentionDays,
139
+ sweepIntervalHours: config.sweepIntervalHours,
140
+ enableAutoCleanup: config.enableAutoCleanup,
141
+ },
142
+ trashRootDisplay,
143
+ index: store.describe(),
144
+ entries,
145
+ stats: {
146
+ count: entries.length,
147
+ totalBytes,
148
+ failedEntries,
149
+ nextSweepAt: diagnostics.nextSweepAt ?? nextSweepAt(),
150
+ cleanup: diagnostics,
151
+ },
152
+ }
153
+ },
154
+ }
155
+ }