dsh-webui-studio 0.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.
@@ -0,0 +1,321 @@
1
+ import { StudioAgentController } from './agent.js';
2
+ import { StudioBuildError, StudioBuildRunner } from './build.js';
3
+ import { StudioPreviewSupervisor } from './preview.js';
4
+ import { applyProjectPatch, listProjectFiles, readProjectFile, writeProjectFile } from './project-files.js';
5
+ import { inspectReadiness, StudioPackRunner } from './readiness.js';
6
+ import { assertDraftPackageIdentity } from './runtime-profile.js';
7
+ function failure(rpcId, code, message, details = {}) {
8
+ return { type: 'server-response', rpcId, result: { ok: false, error: { code, message, details } } };
9
+ }
10
+ function success(rpcId, value) {
11
+ return { type: 'server-response', rpcId, result: { ok: true, value } };
12
+ }
13
+ function objectPayload(payload) {
14
+ if (typeof payload !== 'object' || payload === null)
15
+ throw new Error('request payload must be an object');
16
+ return payload;
17
+ }
18
+ function draftId(payload) {
19
+ const id = objectPayload(payload).draftId;
20
+ if (typeof id !== 'string')
21
+ throw new Error('draftId is required');
22
+ return id;
23
+ }
24
+ class StudioDraftController {
25
+ record;
26
+ projectState;
27
+ previewState = { connected: false, mode: 'browse' };
28
+ builds;
29
+ packs;
30
+ agent;
31
+ preview;
32
+ constructor(record, profileDir, parentOrigin, commands, harmonyBinEntry, agents, subprocess) {
33
+ this.record = record;
34
+ this.preview = new StudioPreviewSupervisor(record, profileDir, parentOrigin, commands, harmonyBinEntry);
35
+ this.builds = new StudioBuildRunner(subprocess);
36
+ this.packs = new StudioPackRunner(subprocess);
37
+ this.agent = new StudioAgentController(agents, this);
38
+ }
39
+ view() {
40
+ const agent = this.agent.snapshot();
41
+ return {
42
+ ...this.record,
43
+ runtime: this.preview.snapshot(),
44
+ ...(this.projectState === undefined ? {} : { project: this.projectState }),
45
+ ...(agent === undefined ? {} : { agent }),
46
+ };
47
+ }
48
+ async start() {
49
+ await this.preview.start();
50
+ this.projectState = await this.preview.state();
51
+ return this.view();
52
+ }
53
+ async stop() {
54
+ await this.agent.dispose();
55
+ await this.builds.cancel();
56
+ await this.preview.stop();
57
+ this.projectState = undefined;
58
+ this.previewState = { connected: false, mode: 'browse' };
59
+ return this.view();
60
+ }
61
+ async dispose() {
62
+ await this.agent.dispose();
63
+ await this.builds.dispose();
64
+ await this.packs.dispose();
65
+ await this.preview.dispose();
66
+ }
67
+ project() {
68
+ if (this.projectState === undefined)
69
+ throw new Error('Draft Preview Host is not running');
70
+ return this.projectState;
71
+ }
72
+ async refreshProject() {
73
+ this.projectState = await this.preview.state();
74
+ return this.projectState;
75
+ }
76
+ async activate(graphRev) {
77
+ this.projectState = await this.preview.activate(graphRev);
78
+ return this.projectState;
79
+ }
80
+ selection() {
81
+ return this.previewState.selection;
82
+ }
83
+ updatePreview(update) {
84
+ const next = { ...this.previewState, ...update };
85
+ if (next.selection === null)
86
+ delete next.selection;
87
+ if (next.registry === null)
88
+ delete next.registry;
89
+ this.previewState = next;
90
+ return this.previewState;
91
+ }
92
+ previewStatus() {
93
+ return this.previewState;
94
+ }
95
+ resolveSource(source) {
96
+ return this.preview.resolveSource(source);
97
+ }
98
+ readDependencySource(packageName, file) {
99
+ return this.preview.readDependencySource(packageName, file);
100
+ }
101
+ async inspectHarmony(input) {
102
+ return (await this.preview.inspect(input)).harmony;
103
+ }
104
+ async readiness() {
105
+ const inspection = await this.preview.inspect();
106
+ return inspectReadiness(this.record.root, this.record.name, inspection.harmony, `${this.record.runtimeHome}/profiles/web`, inspection.dependencies);
107
+ }
108
+ async pack() {
109
+ const report = await this.readiness();
110
+ report.pack = await this.packs.run(this.record.root);
111
+ return report;
112
+ }
113
+ async readFile(path) {
114
+ return readProjectFile(this.record.root, path);
115
+ }
116
+ async applyPatch(path, before, after) {
117
+ return applyProjectPatch(this.record.root, path, before, after);
118
+ }
119
+ async build(signal) {
120
+ const current = this.project();
121
+ if (current.state !== 'active')
122
+ throw new Error('Draft must be active before it can be built');
123
+ await assertDraftPackageIdentity(this.record);
124
+ const build = await this.builds.run(current.root, signal);
125
+ this.projectState = await this.preview.applyBuild();
126
+ return { build, project: this.projectState };
127
+ }
128
+ cancelBuild() {
129
+ return this.builds.cancel();
130
+ }
131
+ createAgent(agentPreset) {
132
+ return this.agent.create(agentPreset);
133
+ }
134
+ async disposeAgent() {
135
+ await this.agent.dispose();
136
+ }
137
+ }
138
+ /** Stable-Host control plane for persistent, isolated Draft Preview runtimes. */
139
+ export class StudioBackend {
140
+ harmony;
141
+ agents;
142
+ subprocess;
143
+ registry;
144
+ workspace;
145
+ commands;
146
+ parentOrigin;
147
+ controllers = new Map();
148
+ controllerCreations = new Map();
149
+ constructor(harmony, agents, subprocess, registry, workspace, commands, parentOrigin) {
150
+ this.harmony = harmony;
151
+ this.agents = agents;
152
+ this.subprocess = subprocess;
153
+ this.registry = registry;
154
+ this.workspace = workspace;
155
+ this.commands = commands;
156
+ this.parentOrigin = parentOrigin;
157
+ }
158
+ async call(message) {
159
+ const { method, payload, rpcId } = message;
160
+ try {
161
+ if (method === 'studio.drafts.list')
162
+ return success(rpcId, await this.list());
163
+ if (method === 'studio.drafts.create')
164
+ return success(rpcId, await this.create(payload));
165
+ if (method === 'studio.workspace.get') {
166
+ const records = await this.registry.list();
167
+ return success(rpcId, await this.workspace.read(records.map(record => record.id)));
168
+ }
169
+ if (method === 'studio.workspace.update') {
170
+ const records = await this.registry.list();
171
+ return success(rpcId, await this.workspace.write(objectPayload(payload), records.map(record => record.id)));
172
+ }
173
+ const controller = await this.controller(draftId(payload));
174
+ if (method === 'studio.drafts.rename') {
175
+ const label = objectPayload(payload).label;
176
+ if (typeof label !== 'string')
177
+ throw new Error('Draft name is required');
178
+ const record = await this.registry.rename(controller.record.id, label);
179
+ controller.record = record;
180
+ return success(rpcId, controller.view());
181
+ }
182
+ if (method === 'studio.drafts.export') {
183
+ const record = await this.registry.export(controller.record.id);
184
+ controller.record = record;
185
+ return success(rpcId, controller.view());
186
+ }
187
+ if (method === 'studio.drafts.start')
188
+ return success(rpcId, await controller.start());
189
+ if (method === 'studio.drafts.stop')
190
+ return success(rpcId, await controller.stop());
191
+ if (method === 'studio.project.state')
192
+ return success(rpcId, await controller.refreshProject());
193
+ if (method === 'studio.project.activate') {
194
+ const graphRev = objectPayload(payload).graphRev;
195
+ if (typeof graphRev !== 'string')
196
+ throw new Error('graphRev is required');
197
+ return success(rpcId, await controller.activate(graphRev));
198
+ }
199
+ if (method === 'studio.project.files')
200
+ return success(rpcId, await listProjectFiles(controller.record.root));
201
+ if (method === 'studio.project.readFile') {
202
+ const path = objectPayload(payload).path;
203
+ if (typeof path !== 'string')
204
+ throw new Error('path is required');
205
+ return success(rpcId, { path, content: await controller.readFile(path) });
206
+ }
207
+ if (method === 'studio.project.writeFile') {
208
+ const { path, content } = objectPayload(payload);
209
+ if (typeof path !== 'string' || typeof content !== 'string')
210
+ throw new Error('path and content are required');
211
+ await writeProjectFile(controller.record.root, path, content);
212
+ return success(rpcId, { path, saved: true });
213
+ }
214
+ if (method === 'studio.project.build')
215
+ return success(rpcId, await controller.build(new AbortController().signal));
216
+ if (method === 'studio.project.cancelBuild')
217
+ return success(rpcId, { canceled: await controller.cancelBuild() });
218
+ if (method === 'studio.readiness.inspect')
219
+ return success(rpcId, await controller.readiness());
220
+ if (method === 'studio.readiness.pack')
221
+ return success(rpcId, await controller.pack());
222
+ if (method === 'studio.harmony.inspect') {
223
+ const input = objectPayload(payload);
224
+ return success(rpcId, await controller.inspectHarmony({
225
+ ...(typeof input.package === 'string' ? { package: input.package } : {}),
226
+ ...(typeof input.file === 'string' ? { file: input.file } : {}),
227
+ }));
228
+ }
229
+ if (method === 'studio.preview.status')
230
+ return success(rpcId, controller.previewStatus());
231
+ if (method === 'studio.preview.update')
232
+ return success(rpcId, controller.updatePreview(this.previewStatus(payload)));
233
+ if (method === 'studio.preview.resolveSource') {
234
+ const source = objectPayload(payload).source;
235
+ if (typeof source?.file !== 'string')
236
+ throw new Error('source is required');
237
+ return success(rpcId, await controller.resolveSource(source));
238
+ }
239
+ if (method === 'studio.agent.create') {
240
+ const preset = objectPayload(payload).agentPreset;
241
+ if (preset !== undefined && typeof preset !== 'string')
242
+ throw new Error('agentPreset must be a string');
243
+ return success(rpcId, await controller.createAgent(preset));
244
+ }
245
+ if (method === 'studio.agent.dispose') {
246
+ await controller.disposeAgent();
247
+ return success(rpcId, { disposed: true });
248
+ }
249
+ return failure(rpcId, 'studio-method-forbidden', `method ${method} is not exposed by Studio`);
250
+ }
251
+ catch (error) {
252
+ const code = error instanceof StudioBuildError ? error.code : 'studio-request-failed';
253
+ const details = error instanceof StudioBuildError ? error.output : undefined;
254
+ return failure(rpcId, code, error instanceof Error ? error.message : String(error), details);
255
+ }
256
+ }
257
+ async dispose() {
258
+ await Promise.all([...this.controllerCreations.values()].map(creation => creation.catch(() => undefined)));
259
+ await Promise.all([...this.controllers.values()].map(controller => controller.dispose()));
260
+ this.controllers.clear();
261
+ this.controllerCreations.clear();
262
+ }
263
+ async list() {
264
+ const records = await this.registry.list();
265
+ return records.map(record => this.controllers.get(record.id)?.view() ?? {
266
+ ...record,
267
+ runtime: { state: 'stopped', log: '' },
268
+ });
269
+ }
270
+ async create(payload) {
271
+ const candidate = objectPayload(payload);
272
+ if ((candidate.profileMode !== 'main-home' && candidate.profileMode !== 'custom')
273
+ || typeof candidate.source !== 'object' || candidate.source === null
274
+ || (candidate.source.kind !== 'new' && candidate.source.kind !== 'existing')
275
+ || (candidate.destinationDirectory !== undefined && typeof candidate.destinationDirectory !== 'string')) {
276
+ throw new Error('Draft source and profileMode are invalid');
277
+ }
278
+ const record = await this.registry.create(candidate);
279
+ return this.makeController(record).view();
280
+ }
281
+ async controller(id) {
282
+ const current = this.controllers.get(id);
283
+ if (current !== undefined)
284
+ return current;
285
+ const pending = this.controllerCreations.get(id);
286
+ if (pending !== undefined)
287
+ return pending;
288
+ const creation = this.registry.get(id).then(record => this.controllers.get(id) ?? this.makeController(record));
289
+ this.controllerCreations.set(id, creation);
290
+ try {
291
+ return await creation;
292
+ }
293
+ finally {
294
+ if (this.controllerCreations.get(id) === creation)
295
+ this.controllerCreations.delete(id);
296
+ }
297
+ }
298
+ makeController(record) {
299
+ const controller = new StudioDraftController(record, this.harmony.profileDir, this.parentOrigin, this.commands, this.harmony.binEntry, this.agents, this.subprocess);
300
+ this.controllers.set(record.id, controller);
301
+ return controller;
302
+ }
303
+ previewStatus(payload) {
304
+ const candidate = objectPayload(payload);
305
+ if (typeof candidate.connected !== 'boolean' || (candidate.mode !== 'browse' && candidate.mode !== 'inspect')
306
+ || (candidate.graphRev !== undefined && typeof candidate.graphRev !== 'string')) {
307
+ throw new Error('Preview status is invalid');
308
+ }
309
+ if (candidate.registry !== undefined && candidate.registry !== null && (typeof candidate.registry !== 'object'
310
+ || !Array.isArray(candidate.registry.elements) || !Array.isArray(candidate.registry.variables))) {
311
+ throw new Error('Preview registry is invalid');
312
+ }
313
+ return {
314
+ connected: candidate.connected,
315
+ mode: candidate.mode,
316
+ ...(candidate.graphRev === undefined ? {} : { graphRev: candidate.graphRev }),
317
+ ...(candidate.selection === undefined ? {} : { selection: candidate.selection }),
318
+ ...(candidate.registry === undefined ? {} : { registry: candidate.registry }),
319
+ };
320
+ }
321
+ }
@@ -0,0 +1,26 @@
1
+ import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
2
+ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
3
+ export interface StudioBuildOutput {
4
+ argv: string[];
5
+ stdout: string;
6
+ stderr: string;
7
+ truncated: boolean;
8
+ }
9
+ export declare class StudioBuildError extends Error {
10
+ readonly code: 'studio-build-busy' | 'studio-build-config' | 'studio-build-failed' | 'studio-build-timeout' | 'studio-build-canceled';
11
+ readonly output?: StudioBuildOutput | undefined;
12
+ constructor(code: 'studio-build-busy' | 'studio-build-config' | 'studio-build-failed' | 'studio-build-timeout' | 'studio-build-canceled', message: string, output?: StudioBuildOutput | undefined);
13
+ }
14
+ export declare function resolvePackageManager(root: string, manifest: {
15
+ packageManager?: unknown;
16
+ }): PackageManager;
17
+ export declare function resolveBuildArgv(root: string): string[];
18
+ export declare class StudioBuildRunner {
19
+ private readonly subprocess;
20
+ private readonly timeoutMs;
21
+ private active?;
22
+ constructor(subprocess: SubprocessRuntime, timeoutMs?: number);
23
+ run(root: string, signal?: AbortSignal): Promise<StudioBuildOutput>;
24
+ cancel(): Promise<boolean>;
25
+ dispose(): Promise<void>;
26
+ }
@@ -0,0 +1,137 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const BUILD_TIMEOUT_MS = 120_000;
4
+ const OUTPUT_LIMIT_BYTES = 256 * 1024;
5
+ export class StudioBuildError extends Error {
6
+ code;
7
+ output;
8
+ constructor(code, message, output) {
9
+ super(message);
10
+ this.code = code;
11
+ this.output = output;
12
+ this.name = 'StudioBuildError';
13
+ }
14
+ }
15
+ export function resolvePackageManager(root, manifest) {
16
+ if (manifest.packageManager !== undefined) {
17
+ if (typeof manifest.packageManager !== 'string') {
18
+ throw new StudioBuildError('studio-build-config', 'Draft packageManager must be a string');
19
+ }
20
+ const name = manifest.packageManager.split('@', 1)[0];
21
+ if (name === 'npm' || name === 'pnpm' || name === 'yarn' || name === 'bun')
22
+ return name;
23
+ throw new StudioBuildError('studio-build-config', `Draft packageManager ${JSON.stringify(name)} is not supported`);
24
+ }
25
+ const candidates = [
26
+ { name: 'pnpm', files: ['pnpm-lock.yaml'] },
27
+ { name: 'npm', files: ['package-lock.json'] },
28
+ { name: 'yarn', files: ['yarn.lock'] },
29
+ { name: 'bun', files: ['bun.lock', 'bun.lockb'] },
30
+ ];
31
+ const matches = candidates.filter(candidate => candidate.files.some(file => existsSync(join(root, file))));
32
+ if (matches.length === 0) {
33
+ throw new StudioBuildError('studio-build-config', 'Draft must declare packageManager or contain a supported lockfile');
34
+ }
35
+ if (matches.length > 1) {
36
+ throw new StudioBuildError('studio-build-config', 'Draft contains lockfiles for multiple package managers');
37
+ }
38
+ return matches[0].name;
39
+ }
40
+ export function resolveBuildArgv(root) {
41
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
42
+ if (typeof manifest.scripts?.build !== 'string' || manifest.scripts.build.trim() === '') {
43
+ throw new StudioBuildError('studio-build-config', 'Draft must define a non-empty scripts.build');
44
+ }
45
+ return [resolvePackageManager(root, manifest), 'run', 'build'];
46
+ }
47
+ function outputOf(handle, argv) {
48
+ const stdout = handle.collected.stdout?.readFrom(0);
49
+ const stderr = handle.collected.stderr?.readFrom(0);
50
+ return {
51
+ argv,
52
+ stdout: stdout?.text ?? '',
53
+ stderr: stderr?.text ?? '',
54
+ truncated: stdout?.lossy === true || stderr?.lossy === true,
55
+ };
56
+ }
57
+ export class StudioBuildRunner {
58
+ subprocess;
59
+ timeoutMs;
60
+ active;
61
+ constructor(subprocess, timeoutMs = BUILD_TIMEOUT_MS) {
62
+ this.subprocess = subprocess;
63
+ this.timeoutMs = timeoutMs;
64
+ }
65
+ async run(root, signal) {
66
+ if (this.active !== undefined)
67
+ throw new StudioBuildError('studio-build-busy', 'a Draft build is already running');
68
+ const argv = resolveBuildArgv(root);
69
+ const active = { controller: new AbortController(), canceled: false, timedOut: false };
70
+ this.active = active;
71
+ const externalAbort = () => {
72
+ active.canceled = true;
73
+ active.controller.abort();
74
+ };
75
+ signal?.addEventListener('abort', externalAbort, { once: true });
76
+ if (signal?.aborted === true)
77
+ externalAbort();
78
+ const timeout = setTimeout(() => {
79
+ active.timedOut = true;
80
+ active.controller.abort();
81
+ }, this.timeoutMs);
82
+ try {
83
+ argv[0] = await this.subprocess.resolveExecutable(argv[0], undefined, active.controller.signal);
84
+ active.handle = this.subprocess.spawn({
85
+ argv,
86
+ cwd: root,
87
+ stdio: {
88
+ stdin: 'ignore',
89
+ stdout: { maxBytes: OUTPUT_LIMIT_BYTES },
90
+ stderr: { maxBytes: OUTPUT_LIMIT_BYTES },
91
+ },
92
+ graceMs: 2_000,
93
+ signal: active.controller.signal,
94
+ });
95
+ const outcome = await active.handle.done;
96
+ const output = outputOf(active.handle, argv);
97
+ if (active.timedOut)
98
+ throw new StudioBuildError('studio-build-timeout', 'Draft build timed out', output);
99
+ if (active.canceled)
100
+ throw new StudioBuildError('studio-build-canceled', 'Draft build was canceled', output);
101
+ if (outcome.exitCode !== 0) {
102
+ throw new StudioBuildError('studio-build-failed', `Draft build exited with ${outcome.exitCode === null ? outcome.signal ?? 'a signal' : `code ${outcome.exitCode}`}`, output);
103
+ }
104
+ return output;
105
+ }
106
+ catch (error) {
107
+ if (error instanceof StudioBuildError)
108
+ throw error;
109
+ if (active.timedOut)
110
+ throw new StudioBuildError('studio-build-timeout', 'Draft build timed out');
111
+ if (active.canceled)
112
+ throw new StudioBuildError('studio-build-canceled', 'Draft build was canceled');
113
+ throw new StudioBuildError('studio-build-failed', error instanceof Error ? error.message : String(error));
114
+ }
115
+ finally {
116
+ clearTimeout(timeout);
117
+ signal?.removeEventListener('abort', externalAbort);
118
+ if (active.controller.signal.aborted && active.handle !== undefined)
119
+ await active.handle.waitForExit();
120
+ if (this.active === active)
121
+ this.active = undefined;
122
+ }
123
+ }
124
+ async cancel() {
125
+ const active = this.active;
126
+ if (active === undefined)
127
+ return false;
128
+ active.canceled = true;
129
+ active.controller.abort();
130
+ if (active.handle !== undefined)
131
+ await active.handle.waitForExit();
132
+ return true;
133
+ }
134
+ async dispose() {
135
+ await this.cancel();
136
+ }
137
+ }
@@ -0,0 +1,23 @@
1
+ import type { StudioCreateDraftInput, StudioDraftRecord } from '../contracts.js';
2
+ export interface StudioCommandRunner {
3
+ run(command: string, args: string[], cwd?: string, onOutput?: (chunk: string) => void, signal?: AbortSignal): Promise<void>;
4
+ }
5
+ export declare const studioCommands: StudioCommandRunner;
6
+ export declare class StudioDraftRegistry {
7
+ private readonly commands;
8
+ readonly root: string;
9
+ readonly recordsDir: string;
10
+ readonly repositoriesDir: string;
11
+ readonly worktreesDir: string;
12
+ readonly runtimesDir: string;
13
+ private readonly recordMutations;
14
+ constructor(dshHome: string, commands?: StudioCommandRunner);
15
+ list(): Promise<StudioDraftRecord[]>;
16
+ get(id: string): Promise<StudioDraftRecord>;
17
+ create(input: StudioCreateDraftInput): Promise<StudioDraftRecord>;
18
+ rename(id: string, label: string): Promise<StudioDraftRecord>;
19
+ export(id: string): Promise<StudioDraftRecord>;
20
+ private mutate;
21
+ private replace;
22
+ }
23
+ export declare function dshHomeFromProfile(profileDir: string): string;