dsh-computers 0.0.0-stage → 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/lib/runtime.js ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Embedded runtime mode (PROTOCOL §10.2–§10.4).
3
+ *
4
+ * The plugin can also BE a controlled machine: this manager persists
5
+ * `${stateDir}/runtime.json`, enrolls this machine with one hub, and drives
6
+ * the agent's exported `startRuntime()` — it never reimplements the SSE/exec
7
+ * wire protocol, it only owns the user's switch and the process lifecycle.
8
+ *
9
+ * Topology stays hub-and-spoke: the runtime half only ever dials out to the
10
+ * single configured `serverUrl`, never to another machine.
11
+ *
12
+ * Node builtins only.
13
+ */
14
+
15
+ import { randomBytes } from 'node:crypto'
16
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
17
+ import { arch, cpus, hostname, platform, totalmem } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ import { HttpError, postJson } from './http.js'
21
+ import { platformLabel } from './keys.js'
22
+
23
+ /** On-disk schema version for runtime.json. */
24
+ export const RUNTIME_STATE_VERSION = 1
25
+
26
+ /** File name inside the plugin state directory. */
27
+ export const RUNTIME_FILE_NAME = 'runtime.json'
28
+
29
+ /** Enroll POST timeout: the hub is a remote machine, so be patient but bounded. */
30
+ const ENROLL_TIMEOUT_MS = 10_000
31
+
32
+ /** Normalize an ISO-ish timestamp or null. */
33
+ const asIsoOrNull = (value) => {
34
+ if (typeof value !== 'string') return null
35
+ const at = Date.parse(value)
36
+ return Number.isFinite(at) ? new Date(at).toISOString() : null
37
+ }
38
+
39
+ /**
40
+ * Validate and normalize a hub server URL: absolute http(s), no trailing slash.
41
+ * @param {unknown} raw - candidate from the config/route body.
42
+ * @returns {string} canonical origin.
43
+ */
44
+ export function normalizeServerUrl(raw) {
45
+ if (typeof raw !== 'string' || raw.trim() === '') {
46
+ throw new HttpError(400, 'bad-request', 'serverUrl is required')
47
+ }
48
+ let parsed
49
+ try {
50
+ parsed = new URL(raw.trim())
51
+ } catch {
52
+ throw new HttpError(400, 'bad-request', `serverUrl is not a valid URL: ${JSON.stringify(raw)}`)
53
+ }
54
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
55
+ throw new HttpError(400, 'bad-request', 'serverUrl must use http or https')
56
+ }
57
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
58
+ }
59
+
60
+ /**
61
+ * Coerce a parsed runtime.json into the documented shape, or null when it is
62
+ * not a usable record. A corrupt file is set aside, never fatal.
63
+ * @param {unknown} raw - parsed JSON.
64
+ * @returns {object | null} normalized document.
65
+ */
66
+ export function normalizeRuntimeState(raw) {
67
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
68
+ if (typeof raw.serverUrl !== 'string' || raw.serverUrl.trim() === '') return null
69
+ if (typeof raw.computerId !== 'string' || raw.computerId.trim() === '') return null
70
+ if (typeof raw.token !== 'string' || raw.token.trim() === '') return null
71
+ return {
72
+ version: RUNTIME_STATE_VERSION,
73
+ serverUrl: raw.serverUrl.trim().replace(/\/+$/, ''),
74
+ computerId: raw.computerId.trim(),
75
+ token: raw.token.trim(),
76
+ name: typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : null,
77
+ enabled: raw.enabled === true,
78
+ enrolledAt: asIsoOrNull(raw.enrolledAt),
79
+ lastConnectedAt: asIsoOrNull(raw.lastConnectedAt),
80
+ lastError: typeof raw.lastError === 'string' && raw.lastError !== '' ? raw.lastError : null,
81
+ }
82
+ }
83
+
84
+ /** Machine facts sent in the §3.1 enroll body. */
85
+ function machineInfo(version) {
86
+ const osName = platform()
87
+ return {
88
+ hostname: hostname(),
89
+ name: hostname(),
90
+ os: osName,
91
+ arch: arch(),
92
+ platform: platformLabel(osName),
93
+ cpuModel: cpus()[0]?.model ?? null,
94
+ memoryBytes: totalmem(),
95
+ nodeVersion: process.version,
96
+ agentVersion: version,
97
+ }
98
+ }
99
+
100
+ /** Map a hub enroll answer onto the §10.5 error vocabulary. */
101
+ function mapEnrollFailure(response) {
102
+ const data = response.data !== null && typeof response.data === 'object' ? response.data : {}
103
+ const hubError = typeof data.error === 'string' ? data.error : ''
104
+ const message = typeof data.message === 'string' && data.message !== ''
105
+ ? data.message
106
+ : hubError !== ''
107
+ ? hubError
108
+ : `the hub answered ${response.status}`
109
+ switch (hubError) {
110
+ case 'invalid-key': return new HttpError(401, 'invalid-key', message)
111
+ case 'key-expired': return new HttpError(410, 'key-expired', message)
112
+ case 'key-used': return new HttpError(409, 'key-used', message)
113
+ case 'bad-request': return new HttpError(400, 'bad-request', message)
114
+ default: break
115
+ }
116
+ switch (response.status) {
117
+ case 401: return new HttpError(401, 'invalid-key', message)
118
+ case 409: return new HttpError(409, 'key-used', message)
119
+ case 410: return new HttpError(410, 'key-expired', message)
120
+ case 400: return new HttpError(400, 'bad-request', message)
121
+ default: return new HttpError(502, 'hub-unreachable', `the hub answered ${response.status}: ${message}`)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Create the runtime manager for one plugin mount.
127
+ *
128
+ * All methods are total for lifecycle paths (they never throw into the host);
129
+ * only `configure()`/`enable()` throw `HttpError`s that the routes translate,
130
+ * because those are direct answers to a user action.
131
+ *
132
+ * @param {object} options - manager options.
133
+ * @param {object} options.config - resolved plugin config (heartbeatMs).
134
+ * @param {string} options.stateDir - directory holding runtime.json.
135
+ * @param {string} [options.version] - plugin version reported at enroll.
136
+ * @param {URL} [options.agentModuleUrl] - agent bundle (default: ../agent/...).
137
+ * @param {(message: string) => void} [options.onWarn] - host warning sink.
138
+ * @returns {object} the manager.
139
+ */
140
+ export function createRuntimeManager({ config, stateDir, version = '1.0.0', agentModuleUrl, onWarn = () => {} }) {
141
+ const file = join(stateDir, RUNTIME_FILE_NAME)
142
+ const moduleUrl = agentModuleUrl ?? new URL('../agent/dsh-computers-agent.mjs', import.meta.url)
143
+
144
+ /** @type {object | null} */
145
+ let doc = null
146
+ /** @type {{ stop: () => Promise<void>, status?: () => object } | null} */
147
+ let handle = null
148
+ let connected = false
149
+ let since = null
150
+ let lastError = null
151
+ let disposed = false
152
+ let agentModulePromise = null
153
+ let warnedLine = null
154
+
155
+ /** Warn once per distinct line so a flapping runtime cannot spam the host. */
156
+ const warnOnce = (message) => {
157
+ if (message === warnedLine) return
158
+ warnedLine = message
159
+ try {
160
+ onWarn(message)
161
+ } catch {
162
+ // A broken log sink must not break the runtime.
163
+ }
164
+ }
165
+
166
+ const log = (level, message) => {
167
+ // Embedded mode: agent info stays quiet; warnings/errors go to the host
168
+ // sink, deduped until the next different line.
169
+ if (level === 'warn' || level === 'error') warnOnce(`dsh-computers runtime: ${message}`)
170
+ }
171
+
172
+ /** Read runtime.json; corrupt files are set aside and treated as unconfigured. */
173
+ const load = () => {
174
+ let raw
175
+ try {
176
+ raw = readFileSync(file, 'utf8')
177
+ } catch (error) {
178
+ if (error && error.code !== 'ENOENT') warnOnce(`dsh-computers: cannot read ${file}: ${error.message}`)
179
+ return null
180
+ }
181
+ try {
182
+ return normalizeRuntimeState(JSON.parse(raw))
183
+ } catch {
184
+ const aside = `${file}.corrupt-${Date.now()}`
185
+ try {
186
+ renameSync(file, aside)
187
+ warnOnce(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
188
+ } catch {
189
+ warnOnce(`dsh-computers: ${file} was not valid JSON`)
190
+ }
191
+ return null
192
+ }
193
+ }
194
+
195
+ /** Write runtime.json atomically (tmp + rename, 0600). */
196
+ const persist = () => {
197
+ if (doc === null) return
198
+ try {
199
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
200
+ const tmp = join(stateDir, `.runtime.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
201
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
202
+ try {
203
+ chmodSync(tmp, 0o600)
204
+ } catch {
205
+ // Platforms without POSIX modes simply skip this.
206
+ }
207
+ renameSync(tmp, file)
208
+ } catch (error) {
209
+ warnOnce(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
210
+ }
211
+ }
212
+
213
+ /** Track one agent state transition; persistence only on real changes. */
214
+ const applyAgentState = (state) => {
215
+ if (state === null || typeof state !== 'object') return
216
+ const nextConnected = state.connected === true
217
+ const nextSince = asIsoOrNull(state.since)
218
+ const nextError = typeof state.lastError === 'string' && state.lastError !== '' ? state.lastError : null
219
+ const changed = nextConnected !== connected
220
+ connected = nextConnected
221
+ since = nextConnected ? (nextSince ?? since ?? new Date().toISOString()) : null
222
+ if (nextError !== null) lastError = nextError
223
+ else if (nextConnected) lastError = null
224
+ if (changed && doc !== null) {
225
+ if (connected) {
226
+ doc.lastConnectedAt = since
227
+ doc.lastError = null
228
+ warnedLine = null
229
+ } else if (lastError !== null) {
230
+ doc.lastError = lastError
231
+ warnOnce(`dsh-computers runtime: disconnected (${lastError})`)
232
+ }
233
+ persist()
234
+ }
235
+ }
236
+
237
+ /** Import the agent bundle once; it must stay side-effect free when imported. */
238
+ const loadAgentModule = async () => {
239
+ if (agentModulePromise === null) {
240
+ agentModulePromise = import(moduleUrl.href)
241
+ .catch((error) => {
242
+ agentModulePromise = null
243
+ throw error
244
+ })
245
+ }
246
+ return await agentModulePromise
247
+ }
248
+
249
+ /** Start the embedded runtime if the user enabled it. Never throws. */
250
+ const start = async () => {
251
+ if (disposed) return
252
+ if (doc === null || doc.enabled !== true) return
253
+ if (handle !== null) return
254
+ let startRuntime
255
+ try {
256
+ const module = await loadAgentModule()
257
+ startRuntime = module.startRuntime
258
+ } catch (error) {
259
+ lastError = `the embedded runtime module could not be loaded: ${error && error.message ? error.message : error}`
260
+ warnOnce(`dsh-computers runtime: ${lastError}`)
261
+ return
262
+ }
263
+ if (typeof startRuntime !== 'function') {
264
+ // Older agent bundle (or a partial install): the enrollment still
265
+ // succeeded, so keep the configuration and explain the missing piece.
266
+ lastError = 'the embedded runtime is unavailable: the agent bundle does not export startRuntime'
267
+ warnOnce(`dsh-computers runtime: ${lastError}`)
268
+ if (doc !== null) {
269
+ doc.lastError = lastError
270
+ persist()
271
+ }
272
+ return
273
+ }
274
+ try {
275
+ const live = startRuntime({
276
+ serverUrl: doc.serverUrl,
277
+ token: doc.token,
278
+ computerId: doc.computerId,
279
+ name: doc.name ?? undefined,
280
+ heartbeatMs: config.heartbeatMs,
281
+ log,
282
+ onState: applyAgentState,
283
+ })
284
+ if (live === null || typeof live !== 'object' || typeof live.stop !== 'function') {
285
+ throw new Error('startRuntime() did not return { stop(), status() }')
286
+ }
287
+ handle = live
288
+ // A synchronous initial status keeps `connected` honest even when the
289
+ // agent only reports transitions later.
290
+ try {
291
+ applyAgentState(live.status?.() ?? { connected: false })
292
+ } catch {
293
+ // A status() that throws is the agent's bug; the loop still runs.
294
+ }
295
+ } catch (error) {
296
+ handle = null
297
+ lastError = `the embedded runtime could not start: ${error && error.message ? error.message : error}`
298
+ warnOnce(`dsh-computers runtime: ${lastError}`)
299
+ }
300
+ }
301
+
302
+ /** Stop the embedded runtime; keeps runtime.json and the user's switch. */
303
+ const stop = async () => {
304
+ const current = handle
305
+ handle = null
306
+ connected = false
307
+ since = null
308
+ if (current === null) return
309
+ try {
310
+ await current.stop()
311
+ } catch (error) {
312
+ warnOnce(`dsh-computers runtime: stop() failed: ${error && error.message ? error.message : error}`)
313
+ }
314
+ }
315
+
316
+ /** Current §10.3 status object. */
317
+ const status = () => {
318
+ const live = (() => {
319
+ try {
320
+ return handle?.status?.() ?? null
321
+ } catch {
322
+ return null
323
+ }
324
+ })()
325
+ const liveConnected = typeof live?.connected === 'boolean' ? live.connected : connected
326
+ const liveSince = asIsoOrNull(live?.since) ?? (liveConnected ? since : null)
327
+ const liveError = typeof live?.lastError === 'string' && live.lastError !== '' ? live.lastError : lastError
328
+ // The client contract (§10.6, implemented by ui-dev) reads `serverUrl` as a
329
+ // string even when unconfigured, so the three identity fields are always
330
+ // strings and only the timestamps are nullable.
331
+ return {
332
+ configured: doc !== null,
333
+ enabled: doc !== null && doc.enabled === true,
334
+ connected: liveConnected === true,
335
+ serverUrl: doc?.serverUrl ?? '',
336
+ computerId: doc?.computerId ?? '',
337
+ name: doc?.name ?? '',
338
+ since: liveConnected === true ? liveSince : null,
339
+ lastError: liveError ?? doc?.lastError ?? null,
340
+ }
341
+ }
342
+
343
+ doc = load()
344
+ if (doc !== null) {
345
+ lastError = doc.lastError ?? null
346
+ // A persisted `lastError` describes the previous process; it is diagnostic
347
+ // only and must not look like a live failure before we try again.
348
+ if (doc.enabled === true) lastError = null
349
+ }
350
+
351
+ return {
352
+ /** Absolute runtime.json path (diagnostics + smoke assertions). */
353
+ file,
354
+ /**
355
+ * Enroll this machine with `serverUrl` using a hub-minted key, persist the
356
+ * result enabled, and start the runtime (PROTOCOL §10.3).
357
+ * @param {{ serverUrl: string, key: string }} input - hub URL and key.
358
+ * @returns {Promise<object>} the new status.
359
+ */
360
+ async configure({ serverUrl, key }) {
361
+ const hubUrl = normalizeServerUrl(serverUrl)
362
+ const enrollmentKey = typeof key === 'string' ? key.trim() : ''
363
+ if (enrollmentKey === '') throw new HttpError(400, 'bad-request', 'key is required')
364
+ // Release the previous runtime first: its token is about to be replaced.
365
+ await stop()
366
+ const response = await postJson(`${hubUrl}/api/dsh-computers/agent/enroll`, {
367
+ key: enrollmentKey,
368
+ agentVersion: version,
369
+ machine: machineInfo(version),
370
+ }, { timeoutMs: ENROLL_TIMEOUT_MS })
371
+ if (response.status !== 200) throw mapEnrollFailure(response)
372
+ const data = response.data !== null && typeof response.data === 'object' ? response.data : {}
373
+ const computerId = typeof data.computerId === 'string' ? data.computerId.trim() : ''
374
+ const token = typeof data.token === 'string' ? data.token.trim() : ''
375
+ if (computerId === '' || token === '') {
376
+ throw new HttpError(502, 'hub-unreachable', 'the hub returned an unexpected enrollment response')
377
+ }
378
+ const enrolledName = typeof data.name === 'string' && data.name.trim() !== ''
379
+ ? data.name.trim()
380
+ : (machineInfo(version).hostname ?? 'this machine')
381
+ doc = {
382
+ version: RUNTIME_STATE_VERSION,
383
+ // Prefer the server URL the hub reported (it knows how clients reach it).
384
+ serverUrl: typeof data.serverUrl === 'string' && data.serverUrl.trim() !== ''
385
+ ? data.serverUrl.trim().replace(/\/+$/, '')
386
+ : hubUrl,
387
+ computerId,
388
+ token,
389
+ name: enrolledName,
390
+ enabled: true,
391
+ enrolledAt: new Date().toISOString(),
392
+ lastConnectedAt: null,
393
+ lastError: null,
394
+ }
395
+ connected = false
396
+ since = null
397
+ lastError = null
398
+ warnedLine = null
399
+ persist()
400
+ await start()
401
+ return status()
402
+ },
403
+ /** Start the embedded runtime when configured and enabled (never throws). */
404
+ async start() {
405
+ await start()
406
+ return status()
407
+ },
408
+ /** Stop the runtime but keep the configuration and the switch (never throws). */
409
+ async stop() {
410
+ await stop()
411
+ return status()
412
+ },
413
+ /** Persist and apply the user's `启用` switch (PROTOCOL §10.3). */
414
+ async enable(enabled) {
415
+ if (typeof enabled !== 'boolean') throw new HttpError(400, 'bad-request', 'enabled must be a boolean')
416
+ if (doc === null) {
417
+ if (enabled === true) throw new HttpError(400, 'bad-request', 'connect this computer to a hub first')
418
+ return status()
419
+ }
420
+ doc.enabled = enabled
421
+ if (enabled) doc.lastError = null
422
+ persist()
423
+ if (enabled) await start()
424
+ else await stop()
425
+ return status()
426
+ },
427
+ /** Stop and forget the configuration (deletes runtime.json). */
428
+ async disconnect() {
429
+ await stop()
430
+ if (doc !== null) {
431
+ try {
432
+ unlinkSync(file)
433
+ } catch (error) {
434
+ if (!error || error.code !== 'ENOENT') {
435
+ warnOnce(`dsh-computers: cannot remove ${file}: ${error && error.message ? error.message : error}`)
436
+ }
437
+ }
438
+ }
439
+ doc = null
440
+ connected = false
441
+ since = null
442
+ lastError = null
443
+ warnedLine = null
444
+ return status()
445
+ },
446
+ /** Protocol status object (§10.3). */
447
+ status,
448
+ /** Start automatically when the persisted switch says enabled. */
449
+ async autoStart() {
450
+ if (doc === null || doc.enabled !== true) return status()
451
+ try {
452
+ await start()
453
+ } catch (error) {
454
+ // start() is total, but a future refactor must not crash the host load.
455
+ warnOnce(`dsh-computers runtime: auto-start failed: ${error && error.message ? error.message : error}`)
456
+ }
457
+ return status()
458
+ },
459
+ /** Plugin unload: stop the loop, keep runtime.json for the next load. */
460
+ async dispose() {
461
+ disposed = true
462
+ await stop()
463
+ },
464
+ /** What the agent currently reports (diagnostics). */
465
+ get connected() {
466
+ return connected
467
+ },
468
+ }
469
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Per-session target-computer selection (task-9).
3
+ *
4
+ * The settings page pins which machine the agent should act on for one chat
5
+ * session; `computer_exec` without an explicit `computer` argument uses that
6
+ * pin. The store is a tiny persisted map `sessionId → computerId` at
7
+ * `${stateDir}/selections.json` (atomic write, mode 0600, corrupt-file
8
+ * tolerant), pruned to the 200 most recently updated entries so a long-lived
9
+ * deployment cannot grow it without bound.
10
+ *
11
+ * Node builtins only.
12
+ */
13
+
14
+ import { randomBytes } from 'node:crypto'
15
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+
18
+ /** On-disk schema version. */
19
+ export const SELECTION_STATE_VERSION = 1
20
+
21
+ /** File name inside the plugin state directory. */
22
+ export const SELECTION_FILE_NAME = 'selections.json'
23
+
24
+ /** How many session selections survive pruning (most recently updated win). */
25
+ export const MAX_SELECTIONS = 200
26
+
27
+ /** Session ids are opaque GUI identifiers; 256 chars is already generous. */
28
+ export const MAX_SESSION_ID_CHARS = 256
29
+
30
+ /**
31
+ * Normalize a session id: non-empty, trimmed, no control characters, bounded
32
+ * length. Returns null for anything else so routes answer 400.
33
+ * @param {unknown} value - candidate (route query/body, agent id).
34
+ * @returns {string | null} the normalized id.
35
+ */
36
+ export function normalizeSessionId(value) {
37
+ if (typeof value !== 'string') return null
38
+ const trimmed = value.trim()
39
+ if (trimmed === '' || trimmed.length > MAX_SESSION_ID_CHARS) return null
40
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) return null
41
+ return trimmed
42
+ }
43
+
44
+ /** A fresh, empty selection document. */
45
+ export function emptySelectionState() {
46
+ return { version: SELECTION_STATE_VERSION, selections: {} }
47
+ }
48
+
49
+ /**
50
+ * Coerce an arbitrary parsed value into a usable selection document.
51
+ * Invalid session keys and malformed entries are dropped, never fatal.
52
+ * @param {unknown} raw - parsed JSON (or undefined when the file was missing).
53
+ * @returns {object} a valid document.
54
+ */
55
+ export function normalizeSelectionState(raw) {
56
+ const doc = emptySelectionState()
57
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
58
+ const source = raw.selections !== null && typeof raw.selections === 'object' && !Array.isArray(raw.selections)
59
+ ? raw.selections
60
+ : {}
61
+ for (const [rawSessionId, entry] of Object.entries(source)) {
62
+ const sessionId = normalizeSessionId(rawSessionId)
63
+ if (sessionId === null) continue
64
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
65
+ const computerId = typeof entry.computerId === 'string' ? entry.computerId.trim() : ''
66
+ if (computerId === '') continue
67
+ const parsed = typeof entry.updatedAt === 'string' ? Date.parse(entry.updatedAt) : Number.NaN
68
+ doc.selections[sessionId] = {
69
+ computerId,
70
+ updatedAt: Number.isFinite(parsed) ? new Date(parsed).toISOString() : null,
71
+ }
72
+ }
73
+ return doc
74
+ }
75
+
76
+ /**
77
+ * Keep only the `max` most recently updated selections (missing timestamps
78
+ * sort last). Mutates in place.
79
+ * @param {object} doc - selection document.
80
+ * @param {number} [max] - entry budget.
81
+ * @returns {number} how many entries were dropped.
82
+ */
83
+ export function pruneSelections(doc, max = MAX_SELECTIONS) {
84
+ const entries = Object.entries(doc.selections)
85
+ if (entries.length <= max) return 0
86
+ const age = (entry) => {
87
+ const parsed = typeof entry?.[1]?.updatedAt === 'string' ? Date.parse(entry[1].updatedAt) : Number.NaN
88
+ return Number.isFinite(parsed) ? parsed : 0
89
+ }
90
+ entries.sort((left, right) => age(right) - age(left))
91
+ const keep = new Set(entries.slice(0, max).map(([sessionId]) => sessionId))
92
+ let dropped = 0
93
+ for (const sessionId of Object.keys(doc.selections)) {
94
+ if (keep.has(sessionId)) continue
95
+ delete doc.selections[sessionId]
96
+ dropped += 1
97
+ }
98
+ return dropped
99
+ }
100
+
101
+ /**
102
+ * Create the selection store for one plugin mount.
103
+ * @param {object} options - store options.
104
+ * @param {string} options.stateDir - directory holding selections.json.
105
+ * @param {() => number} [options.now] - epoch-millisecond clock (testable).
106
+ * @param {(message: string) => void} [options.onWarn] - warning sink.
107
+ * @returns {object} the store.
108
+ */
109
+ export function createSelectionStore({ stateDir, now = Date.now, onWarn = () => {} }) {
110
+ const file = join(stateDir, SELECTION_FILE_NAME)
111
+ let doc = emptySelectionState()
112
+
113
+ /** Read the document; a corrupt file is set aside, never fatal. */
114
+ const load = () => {
115
+ let raw
116
+ try {
117
+ raw = readFileSync(file, 'utf8')
118
+ } catch (error) {
119
+ if (error && error.code !== 'ENOENT') onWarn(`dsh-computers: cannot read ${file}: ${error.message}`)
120
+ return emptySelectionState()
121
+ }
122
+ try {
123
+ return normalizeSelectionState(JSON.parse(raw))
124
+ } catch {
125
+ const aside = `${file}.corrupt-${now()}`
126
+ try {
127
+ renameSync(file, aside)
128
+ onWarn(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
129
+ } catch {
130
+ onWarn(`dsh-computers: ${file} was not valid JSON`)
131
+ }
132
+ return emptySelectionState()
133
+ }
134
+ }
135
+
136
+ /** Write the document atomically: tmp file in the same directory, then rename. */
137
+ const persist = () => {
138
+ try {
139
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
140
+ const tmp = join(stateDir, `.selections.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
141
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
142
+ try {
143
+ chmodSync(tmp, 0o600)
144
+ } catch {
145
+ // Platforms without POSIX modes (Windows) skip this.
146
+ }
147
+ renameSync(tmp, file)
148
+ } catch (error) {
149
+ onWarn(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
150
+ }
151
+ }
152
+
153
+ doc = load()
154
+ pruneSelections(doc)
155
+ if (Object.keys(doc.selections).length > 0) persist()
156
+
157
+ /**
158
+ * Store one selection (or clear it with null), prune and persist.
159
+ * @param {string} sessionId - session identity.
160
+ * @param {string | null} computerId - target computer or null to clear.
161
+ */
162
+ const set = (sessionId, computerId) => {
163
+ const key = normalizeSessionId(sessionId)
164
+ if (key === null) throw new TypeError('dsh-computers selection: sessionId must be a non-empty string')
165
+ if (computerId === null) {
166
+ delete doc.selections[key]
167
+ } else {
168
+ if (typeof computerId !== 'string' || computerId.trim() === '') {
169
+ throw new TypeError('dsh-computers selection: computerId must be a non-empty string or null')
170
+ }
171
+ doc.selections[key] = { computerId: computerId.trim(), updatedAt: new Date(now()).toISOString() }
172
+ }
173
+ pruneSelections(doc)
174
+ persist()
175
+ }
176
+
177
+ return {
178
+ /** Absolute selections.json path (diagnostics + smoke assertions). */
179
+ file,
180
+ /**
181
+ * The selected computer id for one session, or null.
182
+ * @param {string} sessionId - session identity.
183
+ * @returns {string | null} the computer id.
184
+ */
185
+ get(sessionId) {
186
+ const key = normalizeSessionId(sessionId)
187
+ if (key === null) return null
188
+ return doc.selections[key]?.computerId ?? null
189
+ },
190
+ /** Store one selection. */
191
+ set,
192
+ /** Clear one session's selection. */
193
+ clear(sessionId) {
194
+ set(sessionId, null)
195
+ },
196
+ /** Copy of every selection (diagnostics). */
197
+ all() {
198
+ return JSON.parse(JSON.stringify(doc.selections))
199
+ },
200
+ /** Re-read the file (tooling). */
201
+ reload() {
202
+ doc = load()
203
+ },
204
+ }
205
+ }