dsh-session-groups 0.0.0-stage → 0.2.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/index.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * dsh-session-tree host half.
3
+ *
4
+ * The tree itself is a browser-side sidebar enhancement, but its *data* must
5
+ * outlive site-data cleanup and ports: this half owns a small JSON file under
6
+ * the DSH home and exposes it to the browser over the generic authenticated
7
+ * Connection RPC channel (`/session-tree`).
8
+ *
9
+ * No file is read while the plugin loads; the first `load` call decides
10
+ * whether the browser should hydrate from disk or migrate its existing
11
+ * `localStorage` tree.
12
+ *
13
+ * @module dsh-session-groups
14
+ */
15
+
16
+ import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
17
+ import { homedir } from 'node:os'
18
+ import { dirname, join } from 'node:path'
19
+
20
+ /** Stable Cordis plugin name. */
21
+ export const name = 'session-tree'
22
+
23
+ /** No required host services; `connection` is injected opportunistically. */
24
+ export const inject = []
25
+
26
+ const CHANNEL = '/session-tree'
27
+ const FORMAT = 'dsh-session-tree'
28
+ const STATE_VERSION = 1
29
+ const MAX_STATE_BYTES = 4 * 1024 * 1024
30
+ const MAX_WORKSPACES = 2000
31
+ const MAX_GROUPS = 10000
32
+
33
+ let writeChain = Promise.resolve()
34
+
35
+ /** Serialize file operations so two browser tabs cannot interleave writes. */
36
+ function serialize(task) {
37
+ const run = writeChain.then(task, task)
38
+ writeChain = run.catch(() => {})
39
+ return run
40
+ }
41
+
42
+ /** Resolve the same DSH home the rest of the harness uses. */
43
+ function resolveDshHome() {
44
+ const configured = String(process.env.DSH_HOME || '').trim()
45
+ if (configured) {
46
+ if (configured === '~') return homedir()
47
+ if (configured.startsWith('~/') || configured.startsWith('~\\')) {
48
+ return join(homedir(), configured.slice(2))
49
+ }
50
+ return configured
51
+ }
52
+ return join(homedir(), '.dsh')
53
+ }
54
+
55
+ function stateFile() {
56
+ return join(resolveDshHome(), 'storages', 'dsh-session-tree', 'state.json')
57
+ }
58
+
59
+ function backupFile() {
60
+ return stateFile() + '.bak'
61
+ }
62
+
63
+ /**
64
+ * Structural validation only: the browser sanitizes names, parent links and
65
+ * assignments again. This keeps a corrupt or hand-edited file from being
66
+ * written back or served without bound.
67
+ */
68
+ function assertState(value) {
69
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
70
+ throw new Error('state must be an object')
71
+ }
72
+ if (value.version !== STATE_VERSION) {
73
+ throw new Error('unsupported state version')
74
+ }
75
+ if (value.revision !== undefined && (!Number.isFinite(value.revision) || value.revision < 0)) {
76
+ throw new Error('invalid state revision')
77
+ }
78
+ const workspaces = value.workspaces
79
+ if (!workspaces || typeof workspaces !== 'object' || Array.isArray(workspaces)) {
80
+ throw new Error('state.workspaces must be an object')
81
+ }
82
+ const ids = Object.keys(workspaces)
83
+ if (ids.length > MAX_WORKSPACES) throw new Error('too many workspaces')
84
+ let groups = 0
85
+ for (const id of ids) {
86
+ const workspace = workspaces[id]
87
+ if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) {
88
+ throw new Error('invalid workspace entry')
89
+ }
90
+ if (workspace.groups !== undefined && !Array.isArray(workspace.groups)) {
91
+ throw new Error('invalid workspace groups')
92
+ }
93
+ if (
94
+ workspace.assignments !== undefined &&
95
+ (!workspace.assignments || typeof workspace.assignments !== 'object' || Array.isArray(workspace.assignments))
96
+ ) {
97
+ throw new Error('invalid workspace assignments')
98
+ }
99
+ groups += Array.isArray(workspace.groups) ? workspace.groups.length : 0
100
+ }
101
+ if (groups > MAX_GROUPS) throw new Error('too many groups')
102
+ }
103
+
104
+ async function readDocument(path) {
105
+ const raw = await readFile(path, 'utf8')
106
+ if (Buffer.byteLength(raw, 'utf8') > MAX_STATE_BYTES) throw new Error('state file is too large')
107
+ const parsed = JSON.parse(raw)
108
+ if (!parsed || parsed.format !== FORMAT || parsed.version !== STATE_VERSION) {
109
+ throw new Error('unsupported state file')
110
+ }
111
+ assertState(parsed.state)
112
+ return {
113
+ state: parsed.state,
114
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null,
115
+ }
116
+ }
117
+
118
+ async function loadStored() {
119
+ try {
120
+ return await readDocument(stateFile())
121
+ } catch (error) {
122
+ if (error && error.code === 'ENOENT') return { state: null, updatedAt: null }
123
+ console.warn('[dsh-session-tree] could not read host state, trying backup', error)
124
+ try {
125
+ const backup = await readDocument(backupFile())
126
+ console.warn('[dsh-session-tree] recovered host state from backup')
127
+ return backup
128
+ } catch {
129
+ return { state: null, updatedAt: null }
130
+ }
131
+ }
132
+ }
133
+
134
+ async function saveStored(state) {
135
+ assertState(state)
136
+ const file = stateFile()
137
+ const updatedAt = new Date().toISOString()
138
+ const body = JSON.stringify({ format: FORMAT, version: STATE_VERSION, updatedAt, state }, null, 2)
139
+ if (Buffer.byteLength(body, 'utf8') > MAX_STATE_BYTES) throw new Error('state is too large to save')
140
+
141
+ await mkdir(dirname(file), { recursive: true })
142
+ try {
143
+ // Only rotate a readable previous document; a corrupt main file must not
144
+ // replace the last good backup.
145
+ await readDocument(file)
146
+ await copyFile(file, backupFile())
147
+ } catch (error) {
148
+ if (!error || error.code !== 'ENOENT') {
149
+ console.warn('[dsh-session-tree] skipped host state backup', error)
150
+ }
151
+ }
152
+
153
+ const temporary = file + '.' + process.pid + '.' + Date.now().toString(36) + '.tmp'
154
+ await writeFile(temporary, body, 'utf8')
155
+ await rename(temporary, file)
156
+ return updatedAt
157
+ }
158
+
159
+ function failure(code, message) {
160
+ return { ok: false, error: { code, message, details: {} } }
161
+ }
162
+
163
+ function installRpc(scope) {
164
+ const connection = scope.connection || (typeof scope.get === 'function' ? scope.get('connection') : undefined)
165
+ const rpc = connection && connection.rpc
166
+ if (!rpc || typeof rpc.handle !== 'function') {
167
+ console.warn('[dsh-session-tree] connection RPC is unavailable; host persistence is off')
168
+ return
169
+ }
170
+
171
+ rpc.handle(CHANNEL, async (endpoint, payload) => {
172
+ if (endpoint === 'load') {
173
+ try {
174
+ const stored = await serialize(loadStored)
175
+ return { ok: true, value: stored }
176
+ } catch (error) {
177
+ console.warn('[dsh-session-tree] host load failed', error)
178
+ return failure('session-tree/load-failed', 'could not read saved groups')
179
+ }
180
+ }
181
+
182
+ if (endpoint === 'save') {
183
+ const state = payload && typeof payload === 'object' ? payload.state : undefined
184
+ try {
185
+ assertState(state)
186
+ const savedAt = await serialize(() => saveStored(state))
187
+ return { ok: true, value: { savedAt } }
188
+ } catch (error) {
189
+ console.warn('[dsh-session-tree] host save failed', error)
190
+ return failure('session-tree/save-failed', 'could not save groups')
191
+ }
192
+ }
193
+
194
+ return failure('session-tree/not-found', 'unknown endpoint ' + JSON.stringify(String(endpoint)))
195
+ })
196
+ }
197
+
198
+ /** Mount the host half. */
199
+ export function apply(ctx) {
200
+ try {
201
+ ctx.inject(['connection'], (scope) => {
202
+ try {
203
+ installRpc(scope)
204
+ } catch (error) {
205
+ console.warn('[dsh-session-tree] could not register host RPC', error)
206
+ }
207
+ })
208
+ } catch (error) {
209
+ console.warn('[dsh-session-tree] could not inject connection', error)
210
+ }
211
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-session-groups",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "DSH Web GUI sidebar session tree: group sessions inside each workspace into folders, persisted to disk with import/export.",
5
+ "author": "westformula1",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/westformula1/dsh-session-groups.git"
10
+ },
11
+ "homepage": "https://github.com/westformula1/dsh-session-groups#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/westformula1/dsh-session-groups/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "lib/index.js",
17
+ "exports": {
18
+ ".": "./lib/index.js",
19
+ "./client": "./lib/client.js",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "lib",
24
+ "README.md",
25
+ "LICENSE",
26
+ "PUBLISHING.md",
27
+ "assets",
28
+ "cordis.patch.yml"
29
+ ],
30
+ "keywords": [
31
+ "dsh-plugin",
32
+ "dsh-bundle",
33
+ "deepseek-harness",
34
+ "session-groups",
35
+ "sidebar",
36
+ "workspace"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dsh": {
42
+ "engines": {
43
+ "dsh": ">=0.2.2-rc.1 <0.3.0"
44
+ },
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "platform": "web"
50
+ }
51
+ }
52
+ }