dsh-managed-workspaces 0.0.0-stage → 1.1.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/tool.js ADDED
@@ -0,0 +1,278 @@
1
+ // dsh-managed-workspaces: the model-facing tool.
2
+ //
3
+ // The tool calls exactly the store methods the Web UI calls through HTTP, so an
4
+ // operation has one implementation and two callers.
5
+
6
+ import { ManagedWorkspaceError } from './store.js'
7
+
8
+ const ACTIONS = ['status', 'list', 'create', 'adopt', 'attach', 'detach', 'migrate', 'register', 'forget']
9
+
10
+ const PARAMETERS = {
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ properties: {
14
+ action: {
15
+ type: 'string',
16
+ enum: ACTIONS,
17
+ description: 'status reads the current session workspace; list reads every managed workspace and the existing workspaces that are not managed yet; create makes a new workspace folder; adopt registers an existing folder (or every existing sidebar workspace) without moving it; attach/detach add or remove one linked folder; migrate moves the workspace directory while keeping its conversations usable; register re-registers a workspace in the sidebar; forget removes the plugin record without deleting any folder.',
18
+ },
19
+ name: {
20
+ type: 'string',
21
+ description: 'Folder name for action=create, created under the configured base directory.',
22
+ },
23
+ folder: {
24
+ type: 'string',
25
+ description: 'Absolute path of an existing folder: the folder to link for action=attach, or the existing workspace to adopt for action=adopt.',
26
+ },
27
+ all: {
28
+ type: 'boolean',
29
+ description: 'For action=adopt: adopt every sidebar workspace this plugin does not manage yet.',
30
+ },
31
+ destination: {
32
+ type: 'string',
33
+ description: 'Absolute destination path for action=migrate. It must not exist yet; the workspace directory is moved there.',
34
+ },
35
+ mode: {
36
+ type: 'string',
37
+ enum: ['auto', 'anchor', 'clean'],
38
+ description: 'For action=migrate: auto keeps the original path as a link when the workspace still has sessions (required for conversations to keep working) and re-registers a session-less workspace at the new path; anchor always keeps the original path as a link; clean refuses a workspace that still has sessions.',
39
+ },
40
+ alias: {
41
+ type: 'string',
42
+ description: 'Link name under the workspace _roots directory. Defaults to the folder name; ignored for detach when omitted.',
43
+ },
44
+ workspace: {
45
+ type: 'string',
46
+ description: 'Managed workspace id or name. Omit to use the workspace containing the current session directory.',
47
+ },
48
+ },
49
+ required: ['action'],
50
+ }
51
+
52
+ const OUTPUT = {
53
+ type: 'object',
54
+ additionalProperties: false,
55
+ properties: {
56
+ summary: { type: 'string', description: 'Human-readable result of the action.' },
57
+ },
58
+ required: ['summary'],
59
+ }
60
+
61
+ function statusLabel(view) {
62
+ const registration = view.registered ? 'registered' : 'not registered'
63
+ const directory = view.directory === 'ok' ? 'folder ok' : 'folder missing'
64
+ const origin = view.origin === 'adopted' ? 'adopted' : 'created by DSH'
65
+ return `${origin}, ${registration}, ${directory}`
66
+ }
67
+
68
+ function linkLines(view) {
69
+ if (view.links.length === 0) return [' (no linked folders)']
70
+ return view.links.map((link) => ` - ${link.alias} -> ${link.target} [${link.health}]`)
71
+ }
72
+
73
+ function workspaceBlock(view) {
74
+ const lines = [`Workspace "${view.name}" at ${view.path} [${statusLabel(view)}]`]
75
+ if (view.relocatedTo !== null && view.relocatedTo !== undefined) {
76
+ lines.push(`Content location: ${view.relocatedTo} (the original path is kept as a link so recorded sessions stay usable)`)
77
+ }
78
+ lines.push(`Sessions: ${view.sessionCount ?? 0}`)
79
+ lines.push('Linked folders:', ...linkLines(view))
80
+ return lines.join('\n')
81
+ }
82
+
83
+ function listBlock(state) {
84
+ const candidates = Array.isArray(state.candidates) ? state.candidates : []
85
+ const sections = []
86
+ if (state.workspaces.length === 0) {
87
+ sections.push(`No managed workspaces yet. Base directory: ${state.baseDirectory}`)
88
+ } else {
89
+ sections.push(`Base directory: ${state.baseDirectory}`, ...state.workspaces.map(workspaceBlock))
90
+ }
91
+ if (candidates.length > 0) {
92
+ sections.push(
93
+ 'Existing sidebar workspaces that this plugin does not manage yet (adopt them with action=adopt, all=true, or action=adopt with "folder"):',
94
+ ...candidates.map((candidate) => ` - ${candidate.title} (${candidate.path})`),
95
+ )
96
+ }
97
+ return sections.join('\n\n')
98
+ }
99
+
100
+ /**
101
+ * Build the `managed_workspace` tool definition.
102
+ * @param {object} ctx - plugin context used to read the calling session.
103
+ * @param {import('./store.js').ManagedWorkspaceStore} store - state owner.
104
+ * @returns {object} registry-ready tool definition.
105
+ */
106
+ export function createManagedWorkspaceTool(ctx, store) {
107
+ function currentCwd(exec) {
108
+ const session = exec.agent?.session
109
+ if (session === undefined) return undefined
110
+ try {
111
+ return ctx.get('workingDirectory')?.get(session) ?? session.header.cwd
112
+ } catch {
113
+ return session.header.cwd
114
+ }
115
+ }
116
+
117
+ function resolveTarget(args, exec) {
118
+ const records = store.listSync()
119
+ const requested = typeof args.workspace === 'string' ? args.workspace.trim() : ''
120
+ if (requested.length > 0) {
121
+ const match =
122
+ records.find((record) => record.id === requested) ??
123
+ records.find((record) => record.name.toLowerCase() === requested.toLowerCase())
124
+ if (match === undefined) {
125
+ throw new ManagedWorkspaceError(
126
+ 'not-found',
127
+ `unknown managed workspace "${requested}"; available: ${records.map((record) => record.name).join(', ') || '(none)'}`,
128
+ )
129
+ }
130
+ return match
131
+ }
132
+ const cwd = currentCwd(exec)
133
+ const found = cwd === undefined ? null : store.findWorkspaceForPath(cwd)
134
+ if (found !== null) return found.workspace
135
+ throw new ManagedWorkspaceError(
136
+ 'workspace-required',
137
+ records.length === 0
138
+ ? 'no managed workspace exists yet; create one with action=create'
139
+ : `the current directory is not inside a managed workspace; name one with "workspace" (available: ${records.map((record) => record.name).join(', ')})`,
140
+ )
141
+ }
142
+
143
+ return {
144
+ name: 'managed_workspace',
145
+ description: [
146
+ 'Manage DSH workspaces: folders the harness creates under a configured base directory, each of which can own several linked folders, plus existing folders adopted into the same management.',
147
+ 'A linked folder appears inside the workspace as _roots/<alias> and its absolute path stays valid.',
148
+ 'status reports the workspace of the current session; list reports managed workspaces and the existing sidebar workspaces that are not managed yet; create makes a workspace folder and registers it in the sidebar; adopt registers an existing folder (or every existing sidebar workspace with all=true) without moving or creating anything; attach/detach add or remove a linked folder; register restores a sidebar registration; forget removes only the plugin record and never deletes files.',
149
+ ].join(' '),
150
+ parameters: PARAMETERS,
151
+ output: {
152
+ schema: OUTPUT,
153
+ render(_args, value) {
154
+ return [{ type: 'text', text: String(value.summary) }]
155
+ },
156
+ },
157
+ async execute(args, exec) {
158
+ const action = args.action
159
+ if (!ACTIONS.includes(action)) {
160
+ throw new ManagedWorkspaceError('invalid-action', `unknown action: ${JSON.stringify(action)}`)
161
+ }
162
+ await store.load()
163
+
164
+ if (action === 'list') {
165
+ const state = await store.state()
166
+ return { summary: listBlock(state) }
167
+ }
168
+
169
+ if (action === 'status') {
170
+ const cwd = currentCwd(exec)
171
+ const found = cwd === undefined ? null : store.findWorkspaceForPath(cwd)
172
+ if (found === null) {
173
+ const state = await store.state()
174
+ return {
175
+ summary: `The current directory (${cwd ?? 'unknown'}) is not inside a managed workspace.\n\n${listBlock(state)}`,
176
+ }
177
+ }
178
+ const view = await store.view(found.workspace)
179
+ const route = found.via === 'link' ? `through linked folder "${found.alias}"` : 'through the workspace root'
180
+ return { summary: `${workspaceBlock(view)}\nCurrent directory resolves to this workspace ${route}.` }
181
+ }
182
+
183
+ if (action === 'create') {
184
+ const result = await store.create(args.name)
185
+ const warning = result.warning === undefined ? '' : `\nWarning: ${result.warning}`
186
+ return {
187
+ summary: `Created ${workspaceBlock(result.workspace)}${warning}\nStart a session from this workspace in the sidebar to work in it.`,
188
+ }
189
+ }
190
+
191
+ if (action === 'adopt') {
192
+ if (args.all === true) {
193
+ const result = await store.adoptAll()
194
+ const lines = []
195
+ lines.push(
196
+ result.adopted.length === 0
197
+ ? 'No existing workspace needed adoption.'
198
+ : `Adopted ${result.adopted.length} workspace(s): ${result.adopted.join(', ')}.`,
199
+ )
200
+ if (result.skipped.length > 0) lines.push(`Already managed: ${result.skipped.join(', ')}.`)
201
+ if (result.failed.length > 0) lines.push(`Failed:\n${result.failed.map((entry) => ` - ${entry}`).join('\n')}`)
202
+ lines.push('Adoption created no folders and moved nothing; links can be added with action=attach.')
203
+ return { summary: lines.join('\n') }
204
+ }
205
+ if (typeof args.folder !== 'string' || args.folder.trim().length === 0) {
206
+ throw new ManagedWorkspaceError('invalid-target', 'action=adopt requires "folder", or "all": true')
207
+ }
208
+ const result = await store.adopt(args.folder)
209
+ const headline = result.adopted
210
+ ? `Adopted the existing folder as a managed workspace without moving or creating anything inside it.`
211
+ : 'This folder is already managed.'
212
+ const warning = result.warning === undefined ? '' : `\nWarning: ${result.warning}`
213
+ return { summary: `${headline}\n${workspaceBlock(result.workspace)}${warning}` }
214
+ }
215
+
216
+ if (action === 'migrate') {
217
+ const record = resolveTarget(args, exec)
218
+ if (typeof args.destination !== 'string' || args.destination.trim().length === 0) {
219
+ throw new ManagedWorkspaceError('invalid-target', 'action=migrate requires "destination"')
220
+ }
221
+ const result = await store.migrate(record.id, args.destination, { mode: args.mode })
222
+ const explanation =
223
+ result.mode === 'anchor'
224
+ ? `The original path is kept as a directory link to the new location, so the ${result.sessionCount} recorded session(s) and their conversations keep working.`
225
+ : 'The workspace had no sessions, so it was re-registered at the new path with no link left behind.'
226
+ const linkNote =
227
+ result.linkErrors.length === 0 ? '' : `\nLink warnings: ${result.linkErrors.join('; ')}`
228
+ return {
229
+ summary: `Moved the workspace content from ${result.source} to ${result.destination} (${result.movedBy}).\n${explanation}${linkNote}\n${workspaceBlock(result.workspace)}`,
230
+ }
231
+ }
232
+
233
+ if (action === 'register') {
234
+ const record = resolveTarget(args, exec)
235
+ const result = await store.register(record.id)
236
+ return { summary: `Re-registered the workspace.\n${workspaceBlock(result.workspace)}` }
237
+ }
238
+
239
+ if (action === 'forget') {
240
+ const record = resolveTarget(args, exec)
241
+ const result = await store.forget(record.id)
242
+ const registration = result.keptRegistration
243
+ ? ' Its sidebar registration was kept because the folder existed before the plugin.'
244
+ : result.unregistered
245
+ ? ' Its sidebar registration was removed.'
246
+ : ''
247
+ return {
248
+ summary: `Removed workspace "${result.removed}" from the plugin record${registration} Files were not deleted: ${result.path}`,
249
+ }
250
+ }
251
+
252
+ if (action === 'attach') {
253
+ const record = resolveTarget(args, exec)
254
+ if (typeof args.folder !== 'string' || args.folder.trim().length === 0) {
255
+ throw new ManagedWorkspaceError('invalid-target', 'action=attach requires "folder"')
256
+ }
257
+ const result = await store.attach(record.id, args.folder, args.alias)
258
+ const outcome = result.created ? 'Linked' : 'Already linked'
259
+ return {
260
+ summary: `${outcome} ${result.link.target} as _roots/${result.link.alias} [${result.link.health}].\n${workspaceBlock(result.workspace)}`,
261
+ }
262
+ }
263
+
264
+ if (action === 'detach') {
265
+ const record = resolveTarget(args, exec)
266
+ if (typeof args.alias !== 'string' || args.alias.trim().length === 0) {
267
+ throw new ManagedWorkspaceError('invalid-alias', 'action=detach requires "alias"')
268
+ }
269
+ const result = await store.detach(record.id, args.alias)
270
+ return {
271
+ summary: `Removed the link "${result.removed}" from the workspace. The linked folder itself was not touched.\n${workspaceBlock(result.workspace)}`,
272
+ }
273
+ }
274
+
275
+ throw new ManagedWorkspaceError('invalid-action', `action ${JSON.stringify(action)} is not executable`)
276
+ },
277
+ }
278
+ }