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/LICENSE +21 -0
- package/PUBLISHING.md +133 -0
- package/README.md +155 -2
- package/assets/icon.svg +11 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2280 -0
- package/lib/index.js +211 -0
- package/package.json +50 -4
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|