authorclover caruso <git@paperclover.net> 2026-10-13 10:14:41-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
logb3a01d86bc6c4c62f7469c43b02aa53b97fe3f44
treec2b013eb3ba8f1216cfe18386b06be72ade2bf06
parent3ab8402d3db4645647a187fd218bbe385cc6a123
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat(at-work): manage the selected workspace dev frontend

Clover wants the selected workspace frontend beside the composer without leaving orphan dev-tui instances behind. Read dev-tui service status and invoke the existing detached launcher when needed, recognizing its launcher files without a company-specific package name. Preserve drafts and selected worktrees, map loopback URLs to the destination environment, and resume polling after retry while waiting for a connected environment. Provide guarded shutdown for settled or archived workspace owners. Recheck canonical thread, run, request, and native-child ownership under the workspace lease; active peers and pending work retain their runtime. Verify the default socket identity before requesting shutdown, and never substitute the project root when a worktree is missing. Assisted-by: gpt-6.1-sol

16 files changed, 1799 insertions(+), 0 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+64
......@@ -238,6 +238,70 @@ it("requires operate permission for tool updates even alongside a read-only chec
238238 expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope);
239239});
240240
241describe("dev server RPC authority", () => {
242 const tested = [WS_METHODS.devServerStatus, WS_METHODS.devServerStart] as const;
243 const group = WsRpcGroup.omit(
244 ...[...WsRpcGroup.requests.keys()].filter(
245 (tag): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, (typeof tested)[number]> =>
246 !(tested as ReadonlyArray<string>).includes(tag),
247 ),
248 );
249 it.effect.each([
250 {
251 method: WS_METHODS.devServerStart,
252 scopes: [AuthFilesystemReadScope, AuthOrchestrationReadScope],
253 missing: AuthTerminalOperateScope,
254 },
255 {
256 method: WS_METHODS.devServerStatus,
257 scopes: [AuthOrchestrationReadScope],
258 missing: AuthFilesystemReadScope,
259 },
260 {
261 method: WS_METHODS.devServerStatus,
262 scopes: [AuthFilesystemReadScope],
263 missing: AuthOrchestrationReadScope,
264 },
265 ])("rejects $method without $missing before its handler runs", ({ method, scopes, missing }) =>
266 Effect.scoped(
267 Effect.gen(function* () {
268 let handled = false;
269 const response = {
270 workspaceRoot: "/workspace",
271 canStart: false,
272 environment: null,
273 stopping: false,
274 services: [],
275 frontendUrl: null,
276 };
277 const handler = () =>
278 Effect.sync(() => {
279 handled = true;
280 return response;
281 });
282 const client = yield* RpcTest.makeClient(group).pipe(
283 Effect.provide(
284 Layer.mergeAll(
285 group.toLayerHandler(WS_METHODS.devServerStatus, handler),
286 group.toLayerHandler(WS_METHODS.devServerStart, handler),
287 RpcAuthorization.layer(scopes),
288 ),
289 ),
290 );
291 const error = yield* client[method]({
292 _tag: "Thread",
293 threadId: ThreadId.make("thread"),
294 }).pipe(Effect.flip);
295 expect(error).toMatchObject({
296 _tag: "EnvironmentAuthorizationError",
297 requiredPermission: missing,
298 });
299 expect(handled).toBe(false);
300 }),
301 ),
302 );
303});
304
241305describe("RPC scope middleware", () => {
242306 const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const;
243307 const group = WsRpcGroup.omit(
apps/server/src/observability/RpcInstrumentation.ts+2
......@@ -141,6 +141,8 @@ const RPC_AGGREGATES = {
141141 [WS_METHODS.projectCloneRetry]: "source-control",
142142 [WS_METHODS.subscribeProjectClones]: "source-control",
143143 [WS_METHODS.projectsListEntries]: "workspace",
144 [WS_METHODS.devServerStatus]: "workspace",
145 [WS_METHODS.devServerStart]: "workspace",
144146 [WS_METHODS.projectsWatch]: "workspace",
145147 [WS_METHODS.projectsReadFile]: "workspace",
146148 [WS_METHODS.projectsSearchContents]: "workspace",
apps/server/src/server.ts+8
......@@ -118,6 +118,7 @@ import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts";
118118import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
119119import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
120120import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
121import * as DevServer from "./workspace/DevServer.ts";
121122import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
122123import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
123124import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
......@@ -741,6 +742,13 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase
741742 ),
742743 ),
743744 ),
745 Layer.provideMerge(
746 DevServer.layer.pipe(
747 Layer.provide(ProjectionStoreV2.layer),
748 Layer.provide(ProjectStore.layer),
749 Layer.provide(ProcessRunner.layer),
750 ),
751 ),
744752 Layer.provideMerge(layerWorkspace),
745753 Layer.provideMerge(ProjectEnrichmentService.layer),
746754 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)),
apps/server/src/workspace/DevServer.test.ts created+705
......@@ -0,0 +1,705 @@
1// @effect-diagnostics nodeBuiltinImport:off - Creates isolated Unix socket fixtures outside the service.
2import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import {
6 ProjectId,
7 ThreadId,
8 DevServerError,
9 type OrchestrationV2ThreadShell,
10} from "@t3tools/contracts";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as Effect from "effect/Effect";
13import * as DateTime from "effect/DateTime";
14import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import { HttpClient, HttpClientResponse } from "effect/http";
18import * as NodeFSP from "node:fs/promises";
19import * as NodeNet from "node:net";
20import * as NodeOS from "node:os";
21import * as NodePath from "node:path";
22
23import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
24import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
25import * as ProcessRunner from "../processRunner.ts";
26import * as DevServer from "./DevServer.ts";
27
28const target = { _tag: "Thread", threadId: ThreadId.make("thread") } as const;
29let home: string;
30let root: string;
31let projectRoot: string;
32let server: NodeNet.Server | undefined;
33let state: {
34 root: string;
35 dataDir: string;
36 environment: string;
37 stopping: boolean;
38 services: Record<string, { status: string; url: string | null }>;
39};
40let malformed = false;
41let httpStatus = 200;
42const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
43const requestedUrls: string[] = [];
44const commands: ReadonlyArray<string>[] = [];
45let statusObserved: (() => void) | undefined;
46let shutdownCode = 0;
47let snapshotReads = 0;
48let recordsObserved: ((threadId: ThreadId) => void) | undefined;
49type ThreadFields = Pick<
50 OrchestrationV2ThreadShell,
51 | "id"
52 | "projectId"
53 | "worktreePath"
54 | "archivedAt"
55 | "settledOverride"
56 | "settledAt"
57 | "activeRunId"
58 | "activityRunStatus"
59 | "status"
60 | "pendingRuntimeRequest"
61 | "pendingBackgroundTasks"
62 | "deletedAt"
63 | "lineage"
64 | "creationSource"
65>;
66type Thread = { -readonly [K in keyof ThreadFields]: ThreadFields[K] };
67let threadRefs: Thread[];
68let projectRefs: { id: ProjectId; workspaceRoot: string }[];
69let runStatuses: Record<string, string[]>;
70let pendingRequests: Record<string, boolean>;
71function thread(overrides: Partial<Thread> = {}): Thread {
72 return {
73 id: target.threadId,
74 projectId: ProjectId.make("project"),
75 worktreePath: root,
76 archivedAt: null,
77 deletedAt: null,
78 creationSource: "web",
79 lineage: { rootThreadId: target.threadId, parentThreadId: null, relationshipToParent: null },
80 settledOverride: "settled",
81 settledAt: null,
82 activeRunId: null,
83 activityRunStatus: null,
84 status: "completed",
85 pendingRuntimeRequest: null,
86 pendingBackgroundTasks: [],
87 ...overrides,
88 };
89}
90
91beforeEach(async () => {
92 home = await NodeFSP.realpath(await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ds-")));
93 root = NodePath.join(home, "wt");
94 projectRoot = NodePath.join(home, "project");
95 await NodeFSP.mkdir(NodePath.join(root, ".dev-tui"), { recursive: true });
96 await NodeFSP.mkdir(projectRoot);
97 state = {
98 root,
99 dataDir: NodePath.join(root, ".dev-tui"),
100 environment: "local",
101 stopping: false,
102 services: {},
103 };
104 malformed = false;
105 httpStatus = 200;
106 requestedUrls.length = 0;
107 launches.mockReset();
108 commands.length = 0;
109 statusObserved = undefined;
110 shutdownCode = 0;
111 snapshotReads = 0;
112 recordsObserved = undefined;
113 threadRefs = [thread()];
114 projectRefs = [{ id: ProjectId.make("project"), workspaceRoot: projectRoot }];
115 runStatuses = {};
116 pendingRequests = {};
117});
118
119afterEach(async () => {
120 if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
121 server = undefined;
122 await NodeFSP.rm(home, { recursive: true, force: true });
123});
124
125async function socket(socketPath = NodePath.join(root, ".dev-tui", "ipc.sock")) {
126 server = NodeNet.createServer((connection) => {
127 connection.once("data", (command) => {
128 const args = JSON.parse(command.toString()) as string[];
129 commands.push(args);
130 if (args[0] === "shutdown") {
131 connection.end(
132 JSON.stringify({ out: "ok\n" }) + "\n" + JSON.stringify({ code: shutdownCode }) + "\n",
133 );
134 return;
135 }
136 expect(args).toEqual(["status", "--json"]);
137 statusObserved?.();
138 if (malformed) {
139 connection.end("garbage\n");
140 return;
141 }
142 const output = JSON.stringify(state);
143 // Both frames and the JSON payload may cross socket chunk boundaries.
144 connection.write(JSON.stringify({ out: output.slice(0, 10) }) + "\n");
145 const last =
146 JSON.stringify({ out: output.slice(10) }) + "\n" + JSON.stringify({ code: 0 }) + "\n";
147 connection.write(last.slice(0, 5));
148 connection.end(last.slice(5));
149 });
150 });
151 await new Promise<void>((resolve, reject) => {
152 server!.once("error", reject);
153 server!.listen(socketPath, resolve);
154 });
155}
156
157async function workProject() {
158 await NodeFSP.mkdir(NodePath.join(home, "dev", "dev-tui"), { recursive: true });
159 await Promise.all([
160 NodeFSP.writeFile(NodePath.join(root, "dev.sh"), ""),
161 NodeFSP.writeFile(NodePath.join(root, "dev.config.ts"), ""),
162 NodeFSP.writeFile(NodePath.join(home, "dev", "dev-tui", "dev.sh"), ""),
163 ]);
164}
165
166const layer = () =>
167 DevServer.layer.pipe(
168 Layer.provide(
169 Layer.mergeAll(
170 NodeServices.layer,
171 Layer.succeed(HostProcess.HomeDirectory, home),
172 Layer.succeed(HostProcess.Environment, { PATH: "/test/bin" }),
173 Layer.succeed(ProjectStore.ProjectStoreV2, {
174 get: (id: ProjectId) =>
175 Effect.sync(() => Option.fromNullishOr(projectRefs.find((p) => p.id === id))),
176 getShell: (id: ProjectId) =>
177 Effect.sync(() => Option.fromNullishOr(projectRefs.find((p) => p.id === id))),
178 listShells: () => Effect.sync(() => projectRefs.slice()),
179 } as unknown as ProjectStore.ProjectStoreV2["Service"]),
180 Layer.succeed(ProjectionStore.ProjectionStoreV2, {
181 getThread: (id: ThreadId) => Effect.sync(() => threadRefs.find((t) => t.id === id)),
182 getShellSnapshot: () =>
183 Effect.sync(() => {
184 snapshotReads++;
185 return {
186 threads: threadRefs.filter((t) => t.archivedAt === null && t.deletedAt === null),
187 archivedThreads: threadRefs.filter(
188 (t) => t.archivedAt !== null && t.deletedAt === null,
189 ),
190 };
191 }),
192 getThreadRecords: (id: ThreadId) =>
193 Effect.suspend(() => {
194 recordsObserved?.(id);
195 const found = threadRefs.find((t) => t.id === id);
196 return found === undefined
197 ? Effect.fail(
198 new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id }),
199 )
200 : Effect.succeed({
201 thread: found,
202 runs: (runStatuses[id] ?? []).map((status) => ({ status })),
203 runtimeRequests: pendingRequests[id] ? [{ status: "pending" }] : [],
204 });
205 }),
206 } as unknown as ProjectionStore.ProjectionStoreV2["Service"]),
207 Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }),
208 Layer.succeed(
209 HttpClient.HttpClient,
210 HttpClient.make((request) => {
211 requestedUrls.push(request.url);
212 return Effect.succeed(
213 HttpClientResponse.fromWeb(request, new Response(null, { status: httpStatus })),
214 );
215 }),
216 ),
217 ),
218 ),
219 );
220const run = <A>(effect: Effect.Effect<A, DevServerError, DevServer.DevServer>) =>
221 effect.pipe(Effect.provide(layer()));
222const launched = {
223 stdout: "",
224 stderr: "",
225 code: ChildProcessSpawner.ExitCode(0),
226 timedOut: false,
227 stdoutTruncated: false,
228 stderrTruncated: false,
229 stdoutInvalidUtf8: false,
230 stderrInvalidUtf8: false,
231} as const;
232
233describe("DevServer", () => {
234 it.effect("reads this thread's worktree and requires a healthy HTTP frontend", () =>
235 Effect.gen(function* () {
236 yield* Effect.promise(() => socket());
237 state.services = {
238 client: { status: "ready", url: "http://localhost:3210" },
239 api: { status: "crashed", url: null },
240 };
241 const status = yield* run(
242 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
243 );
244 expect(status).toMatchObject({
245 workspaceRoot: root,
246 canStart: false,
247 frontendUrl: "http://localhost:3210/",
248 });
249 expect(status.services).toHaveLength(2);
250 expect(requestedUrls).toEqual(["http://localhost:3210/"]);
251 httpStatus = 503;
252 const unavailable = yield* run(
253 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
254 );
255 expect(unavailable.frontendUrl).toBeNull();
256 }),
257 );
258
259 it.effect("uses the selected pre-send workspace rather than the project's root", () =>
260 Effect.gen(function* () {
261 yield* Effect.promise(() => socket());
262 state.services.client = { status: "ready", url: "http://localhost:3210" };
263 const draft = {
264 _tag: "Project",
265 projectId: ProjectId.make("project"),
266 workspacePath: root,
267 } as const;
268 const status = yield* run(
269 Effect.flatMap(DevServer.DevServer, (service) => service.status(draft)),
270 );
271 expect(status.workspaceRoot).toBe(root);
272 expect(status.frontendUrl).toBe("http://localhost:3210/");
273 }),
274 );
275
276 it.effect("recognizes stopped work project without launching during a status read", () =>
277 Effect.gen(function* () {
278 yield* Effect.promise(workProject);
279 const status = yield* run(
280 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
281 );
282 expect(status).toMatchObject({ canStart: true, services: [], frontendUrl: null });
283 expect(launches).not.toHaveBeenCalled();
284 }),
285 );
286
287 it.effect("never starts arbitrary workspaces or executes status text", () =>
288 Effect.gen(function* () {
289 yield* Effect.promise(() => socket());
290 state.services.client = { status: "starting", url: "bash /tmp/execute-me" };
291 const error = yield* run(
292 Effect.flatMap(DevServer.DevServer, (service) => service.start(target)),
293 ).pipe(Effect.flip);
294 expect(error).toMatchObject({ reason: "unsupported" });
295 expect(launches).not.toHaveBeenCalled();
296 expect(requestedUrls).toEqual([]);
297 }),
298 );
299
300 it.effect.each(["wrong-root", "remote-url", "malformed", "stopping"])(
301 "rejects or withholds a frontend for %s",
302 (scenario) =>
303 Effect.gen(function* () {
304 yield* Effect.promise(() => socket());
305 state.services.client = { status: "ready", url: "http://localhost:3210" };
306 if (scenario === "wrong-root") state.root = projectRoot;
307 if (scenario === "remote-url") state.services.client.url = "https://example.com/";
308 if (scenario === "malformed") malformed = true;
309 if (scenario === "stopping") {
310 state.stopping = true;
311 const status = yield* run(
312 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
313 );
314 expect(status.frontendUrl).toBeNull();
315 } else {
316 const error = yield* run(
317 Effect.flatMap(DevServer.DevServer, (service) => service.status(target)),
318 ).pipe(Effect.flip);
319 expect(error).toMatchObject({ reason: "status" });
320 }
321 expect(requestedUrls).toEqual([]);
322 }),
323 );
324
325 it.effect("deduplicates concurrent launch requests and uses the current detached launcher", () =>
326 Effect.gen(function* () {
327 yield* Effect.promise(workProject);
328 launches.mockImplementation(() =>
329 Effect.promise(async () => {
330 await socket();
331 state.services.client = { status: "ready", url: "http://localhost:3210" };
332 return launched;
333 }),
334 );
335 const results = yield* run(
336 Effect.flatMap(DevServer.DevServer, (service) =>
337 Effect.all([service.start(target), service.start(target)], { concurrency: "unbounded" }),
338 ),
339 );
340 expect(results.map((status) => status.frontendUrl)).toEqual([
341 "http://localhost:3210/",
342 "http://localhost:3210/",
343 ]);
344 expect(launches).toHaveBeenCalledTimes(1);
345 expect(launches).toHaveBeenCalledWith(
346 expect.objectContaining({
347 command: "bash",
348 args: [NodePath.join(root, "dev.sh"), "--headless"],
349 cwd: root,
350 env: { PATH: "/test/bin", DEV_TUI_DIR: NodePath.join(home, "dev", "dev-tui") },
351 }),
352 );
353 }),
354 );
355
356 it.effect("returns a launch error when the detached launcher reports startup failure", () =>
357 Effect.gen(function* () {
358 yield* Effect.promise(workProject);
359 launches.mockImplementation(() =>
360 Effect.succeed({ ...launched, code: ChildProcessSpawner.ExitCode(1) }),
361 );
362 const error = yield* run(
363 Effect.flatMap(DevServer.DevServer, (service) => service.start(target)),
364 ).pipe(Effect.flip);
365 expect(error).toMatchObject({ reason: "launch" });
366 expect(requestedUrls).toEqual([]);
367 }),
368 );
369
370 it.effect("does not open when launcher succeeds before frontend HTTP readiness", () =>
371 Effect.gen(function* () {
372 yield* Effect.promise(workProject);
373 yield* Effect.promise(() => socket());
374 launches.mockImplementation(() =>
375 Effect.sync(() => {
376 state.services.client = { status: "ready", url: "http://localhost:3210" };
377 return launched;
378 }),
379 );
380 httpStatus = 503;
381 const error = yield* run(
382 Effect.flatMap(DevServer.DevServer, (service) => service.start(target)),
383 ).pipe(Effect.flip);
384 expect(error).toMatchObject({ reason: "not-ready" });
385 }),
386 );
387
388 describe("stopUnused", () => {
389 const stop = () =>
390 run(Effect.flatMap(DevServer.DevServer, (service) => service.stopUnused(target.threadId)));
391
392 it.effect(
393 "requests shutdown only through the verified default socket, without launching or deleting a workspace",
394 () =>
395 Effect.gen(function* () {
396 yield* Effect.promise(() => socket());
397 yield* stop();
398 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
399 expect(launches).not.toHaveBeenCalled();
400 expect(requestedUrls).toEqual([]);
401 expect(yield* Effect.promise(() => NodeFSP.stat(root))).toBeDefined();
402 // Acknowledgement is not an exit: a still-running fixture can receive a retry.
403 yield* stop();
404 expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(2);
405 }),
406 );
407
408 it.effect("stops a project-root runtime when the thread has no separate worktree", () =>
409 Effect.gen(function* () {
410 projectRefs[0]!.workspaceRoot = root;
411 threadRefs[0]!.worktreePath = null;
412 yield* Effect.promise(() => socket());
413 yield* stop();
414 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
415 }),
416 );
417
418 it.effect("does not fall back to the project runtime when a managed worktree is gone", () =>
419 Effect.gen(function* () {
420 projectRefs[0]!.workspaceRoot = root;
421 threadRefs[0]!.worktreePath = NodePath.join(home, "missing-worktree");
422 yield* Effect.promise(() => socket());
423 yield* stop();
424 expect(commands).toEqual([]);
425 }),
426 );
427
428 it.effect("missing sockets and missing threads are idempotent", () =>
429 Effect.gen(function* () {
430 yield* stop();
431 expect(snapshotReads).toBe(0);
432 threadRefs.length = 0;
433 yield* stop();
434 expect(commands).toEqual([]);
435 }),
436 );
437
438 it.effect.each(["active-target", "active-peer", "other-project", "descendant", "symlink"])(
439 "protects %s",
440 (scenario) =>
441 Effect.gen(function* () {
442 yield* Effect.promise(() => socket());
443 if (scenario === "active-target") {
444 threadRefs[0]!.settledOverride = "active";
445 } else {
446 const peer = thread({ id: ThreadId.make("peer"), settledOverride: "active" });
447 if (scenario === "other-project") {
448 peer.projectId = ProjectId.make("another-project");
449 peer.worktreePath = null;
450 projectRefs.push({ id: peer.projectId, workspaceRoot: root });
451 }
452 if (scenario === "descendant") {
453 peer.worktreePath = NodePath.join(root, "nested");
454 if (scenario === "descendant")
455 yield* Effect.promise(() => NodeFSP.mkdir(peer.worktreePath!));
456 }
457 if (scenario === "symlink") {
458 peer.worktreePath = NodePath.join(home, "alias");
459 yield* Effect.promise(() => NodeFSP.symlink(root, peer.worktreePath!));
460 }
461 threadRefs.push(peer);
462 }
463 yield* stop();
464 expect(commands).toEqual([]);
465 }),
466 );
467
468 it.effect("a missing peer workspace cannot host or use this runtime", () =>
469 Effect.gen(function* () {
470 threadRefs.push(
471 thread({
472 id: ThreadId.make("missing-peer"),
473 worktreePath: NodePath.join(root, "gone"),
474 settledOverride: "active",
475 }),
476 );
477 yield* Effect.promise(() => socket());
478 yield* stop();
479 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
480 }),
481 );
482
483 it.effect("ignores E2E and custom data directories when the default socket is absent", () =>
484 Effect.gen(function* () {
485 const directory = NodePath.join(root, ".dev-tui-e2e");
486 yield* Effect.promise(() => NodeFSP.mkdir(directory));
487 yield* Effect.promise(() => socket(NodePath.join(directory, "ipc.sock")));
488 yield* stop();
489 expect(commands).toEqual([]);
490 }),
491 );
492
493 it.effect("an unrelated active thread does not protect this workspace", () =>
494 Effect.gen(function* () {
495 threadRefs.push(
496 thread({
497 id: ThreadId.make("elsewhere"),
498 worktreePath: projectRoot,
499 settledOverride: "active",
500 }),
501 );
502 yield* Effect.promise(() => socket());
503 yield* stop();
504 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
505 }),
506 );
507
508 it.effect.each([
509 "queued",
510 "preparing",
511 "starting",
512 "running",
513 "waiting",
514 "request",
515 "background",
516 ])("protects an archived or settled peer with %s work", (scenario) =>
517 Effect.gen(function* () {
518 const peer = thread({
519 id: ThreadId.make("peer"),
520 archivedAt: DateTime.makeUnsafe("2026-10-14T00:00:00Z"),
521 });
522 threadRefs.push(peer);
523 if (scenario === "request") pendingRequests[peer.id] = true;
524 else if (scenario === "background")
525 peer.pendingBackgroundTasks = [{ kind: "monitor", taskId: "watch" }];
526 else runStatuses[peer.id] = [scenario, "completed"];
527 yield* Effect.promise(() => socket());
528 yield* stop();
529 expect(commands).toEqual([]);
530 }),
531 );
532
533 it.effect("stops a deleted thread's runtime without requiring a visible shell", () =>
534 Effect.gen(function* () {
535 threadRefs[0]!.deletedAt = DateTime.makeUnsafe("2026-10-14T00:00:00Z");
536 threadRefs[0]!.settledOverride = null;
537 yield* Effect.promise(() => socket());
538 yield* stop();
539 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
540 }),
541 );
542
543 it.effect.each([
544 "completed",
545 "running",
546 "missing-owner",
547 "active-owner",
548 "mcp-child",
549 "deleted-owner",
550 "parent-owner",
551 ])("handles a %s native child", (scenario) =>
552 Effect.gen(function* () {
553 const child = thread({
554 id: ThreadId.make("child"),
555 settledOverride: null,
556 creationSource: scenario === "mcp-child" ? "mcp" : "provider",
557 lineage: {
558 rootThreadId: scenario === "missing-owner" ? ThreadId.make("missing") : target.threadId,
559 parentThreadId: target.threadId,
560 relationshipToParent: "subagent",
561 },
562 });
563 if (scenario === "parent-owner")
564 child.lineage = { ...child.lineage, rootThreadId: child.id };
565 threadRefs.push(child);
566 if (scenario === "running") runStatuses[child.id] = ["running"];
567 if (scenario === "active-owner") threadRefs[0]!.settledOverride = "active";
568 if (scenario === "deleted-owner") {
569 threadRefs[0]!.deletedAt = DateTime.makeUnsafe("2026-10-14T00:00:00Z");
570 threadRefs[0]!.settledOverride = null;
571 }
572 yield* Effect.promise(() => socket());
573 yield* stop();
574 expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(
575 scenario === "completed" || scenario === "deleted-owner" || scenario === "parent-owner"
576 ? 1
577 : 0,
578 );
579 }),
580 );
581
582 it.effect("leftover commands do not hold a settled workspace open", () =>
583 Effect.gen(function* () {
584 threadRefs[0]!.pendingBackgroundTasks = [{ kind: "command", taskId: "dev-tui" }];
585 yield* Effect.promise(() => socket());
586 yield* stop();
587 expect(commands).toEqual([["status", "--json"], ["shutdown"]]);
588 }),
589 );
590
591 it.effect("a revival during the status probe prevents shutdown", () =>
592 Effect.gen(function* () {
593 yield* Effect.promise(() => socket());
594 statusObserved = () => {
595 threadRefs[0]!.settledOverride = "active";
596 };
597 yield* stop();
598 expect(commands).toEqual([["status", "--json"]]);
599 }),
600 );
601
602 it.effect.each(["active-peer", "queued-child", "moved-target"])(
603 "rechecks %s added during the status probe",
604 (scenario) =>
605 Effect.gen(function* () {
606 yield* Effect.promise(() => socket());
607 statusObserved = () => {
608 if (scenario === "moved-target") threadRefs[0]!.worktreePath = projectRoot;
609 else {
610 const peer = thread({
611 id: ThreadId.make("new-peer"),
612 settledOverride: scenario === "active-peer" ? "active" : "settled",
613 });
614 threadRefs.push(peer);
615 if (scenario === "queued-child") runStatuses[peer.id] = ["queued", "completed"];
616 }
617 };
618 yield* stop();
619 expect(commands).toEqual([["status", "--json"]]);
620 }),
621 );
622
623 it.effect.each(["peer", "native-owner"])(
624 "protects a %s manually revived after the final snapshot",
625 (scenario) =>
626 Effect.gen(function* () {
627 const peerId = ThreadId.make("peer");
628 threadRefs.push(thread({ id: peerId }));
629 if (scenario === "native-owner")
630 threadRefs.splice(
631 1,
632 0,
633 thread({
634 id: ThreadId.make("native-child"),
635 settledOverride: null,
636 creationSource: "provider",
637 lineage: {
638 rootThreadId: peerId,
639 parentThreadId: peerId,
640 relationshipToParent: "subagent",
641 },
642 }),
643 );
644 yield* Effect.promise(() => socket());
645 statusObserved = () => {
646 recordsObserved = (id) => {
647 if (snapshotReads < 2 || id !== peerId) return;
648 threadRefs = threadRefs.map((thread) =>
649 thread.id === peerId ? { ...thread, settledOverride: "active" } : thread,
650 );
651 };
652 };
653 yield* stop();
654 expect(commands).toEqual([["status", "--json"]]);
655 }),
656 );
657
658 it.effect.each(["wrong-root", "custom-data-dir", "symlink-socket", "symlink-data-dir"])(
659 "rejects %s without shutdown",
660 (scenario) =>
661 Effect.gen(function* () {
662 if (scenario === "wrong-root") state.root = projectRoot;
663 if (scenario === "custom-data-dir") state.dataDir = projectRoot;
664 if (scenario === "symlink-socket") {
665 const alternate = NodePath.join(home, "e2e.sock");
666 yield* Effect.promise(() => socket(alternate));
667 yield* Effect.promise(() =>
668 NodeFSP.symlink(alternate, NodePath.join(root, ".dev-tui", "ipc.sock")),
669 );
670 } else if (scenario === "symlink-data-dir") {
671 yield* Effect.promise(() =>
672 NodeFSP.rm(NodePath.join(root, ".dev-tui"), { recursive: true }),
673 );
674 yield* Effect.promise(() =>
675 NodeFSP.symlink(projectRoot, NodePath.join(root, ".dev-tui")),
676 );
677 state.dataDir = projectRoot;
678 yield* Effect.promise(() => socket());
679 } else yield* Effect.promise(() => socket());
680 expect(yield* stop().pipe(Effect.flip)).toMatchObject({ reason: "status" });
681 expect(commands).toEqual([["status", "--json"]]);
682 }),
683 );
684
685 it.effect("a failed shutdown remains retryable", () =>
686 Effect.gen(function* () {
687 yield* Effect.promise(() => socket());
688 shutdownCode = 1;
689 expect(yield* stop().pipe(Effect.flip)).toMatchObject({ reason: "status" });
690 shutdownCode = 0;
691 yield* stop();
692 expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(2);
693 }),
694 );
695
696 it.effect("does not send another shutdown to a stopping runtime", () =>
697 Effect.gen(function* () {
698 state.stopping = true;
699 yield* Effect.promise(() => socket());
700 yield* stop();
701 expect(commands).toEqual([["status", "--json"]]);
702 }),
703 );
704 });
705});
apps/server/src/workspace/DevServer.ts created+415
......@@ -0,0 +1,415 @@
1// @effect-diagnostics nodeBuiltinImport:off - Adapts dev-tui's Unix socket protocol.
2import {
3 DevServerError,
4 isProviderNativeSubagentThread,
5 type OrchestrationV2AppThread,
6 type DevServerStatus,
7 type DevServerTarget,
8 type ThreadId,
9} from "@t3tools/contracts";
10import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19import { FetchHttpClient, HttpClient } from "effect/http";
20import * as NodeNet from "node:net";
21
22import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
23import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
24import * as ProcessRunner from "../processRunner.ts";
25import { withWorkspaceLease } from "./workspaceLease.ts";
26
27const RuntimeStatus = Schema.Struct({
28 root: Schema.String,
29 dataDir: Schema.String,
30 environment: Schema.String,
31 stopping: Schema.Boolean,
32 services: Schema.Record(
33 Schema.String,
34 Schema.Struct({
35 status: Schema.String,
36 url: Schema.NullOr(Schema.String),
37 }),
38 ),
39});
40const decodeStatus = Schema.decodeUnknownSync(Schema.fromJsonString(RuntimeStatus));
41const decodeFrame = Schema.decodeUnknownSync(
42 Schema.fromJsonString(
43 Schema.Struct({
44 out: Schema.optional(Schema.String),
45 code: Schema.optional(Schema.Int),
46 }),
47 ),
48);
49
50function isParked(
51 thread: Pick<
52 OrchestrationV2AppThread,
53 "archivedAt" | "deletedAt" | "settledOverride" | "settledAt"
54 >,
55): boolean {
56 return (
57 thread.deletedAt !== null ||
58 thread.archivedAt !== null ||
59 thread.settledOverride === "settled" ||
60 (thread.settledOverride !== "active" && thread.settledAt !== null)
61 );
62}
63
64const liveRunStatuses = new Set(["queued", "preparing", "starting", "running", "waiting"]);
65
66export class DevServer extends Context.Service<
67 DevServer,
68 {
69 readonly status: (target: DevServerTarget) => Effect.Effect<DevServerStatus, DevServerError>;
70 readonly start: (target: DevServerTarget) => Effect.Effect<DevServerStatus, DevServerError>;
71 readonly stopUnused: (threadId: ThreadId) => Effect.Effect<void, DevServerError>;
72 }
73>()("t3/workspace/DevServer") {}
74
75const make = Effect.gen(function* () {
76 const fs = yield* FileSystem.FileSystem;
77 const http = HttpClient.withScope(yield* HttpClient.HttpClient);
78 const path = yield* Path.Path;
79 const projects = yield* ProjectStore.ProjectStoreV2;
80 const threads = yield* ProjectionStore.ProjectionStoreV2;
81 const processRunner = yield* ProcessRunner.ProcessRunner;
82 const home = yield* HostProcess.HomeDirectory;
83 const environment = yield* HostProcess.Environment;
84 const devTuiDirectory = path.join(home, "dev", "dev-tui");
85
86 const workspace = Effect.fn("DevServer.workspace")(function* (target: DevServerTarget) {
87 const thread =
88 target._tag === "Thread"
89 ? yield* threads
90 .getThread(target.threadId)
91 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })))
92 : null;
93 const projectId = target._tag === "Project" ? target.projectId : thread?.projectId;
94 if (!projectId) return yield* new DevServerError({ reason: "workspace" });
95 const project = yield* projects
96 .get(projectId)
97 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })));
98 if (Option.isNone(project)) return yield* new DevServerError({ reason: "workspace" });
99 const selectedRoot =
100 target._tag === "Project"
101 ? target.workspacePath
102 : (thread?.worktreePath ?? project.value.workspaceRoot);
103 return yield* fs
104 .realPath(selectedRoot)
105 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })));
106 });
107
108 const canStart = Effect.fn("DevServer.canStart")(function* (root: string) {
109 return yield* Effect.all([
110 fs.exists(path.join(root, "dev.sh")),
111 fs.exists(path.join(root, "dev.config.ts")),
112 fs.exists(path.join(devTuiDirectory, "dev.sh")),
113 ]).pipe(
114 Effect.map((present) => present.every(Boolean)),
115 Effect.orElseSucceed(() => false),
116 );
117 });
118
119 const request = (root: string, command: ReadonlyArray<string>) =>
120 Effect.callback<string | null, DevServerError>((resume) => {
121 const socket = NodeNet.connect(path.join(root, ".dev-tui", "ipc.sock"));
122 let frameBuffer = "";
123 let output = "";
124 let settled = false;
125 const finish = (result: Effect.Effect<string | null, DevServerError>) => {
126 if (settled) return;
127 settled = true;
128 socket.destroy();
129 resume(result);
130 };
131 socket.setEncoding("utf8");
132 socket.once("connect", () => socket.write(`${JSON.stringify(command)}\n`));
133 socket.once("error", (cause: NodeJS.ErrnoException) => {
134 finish(
135 cause.code === "ENOENT" || cause.code === "ECONNREFUSED"
136 ? Effect.succeed(null)
137 : Effect.fail(new DevServerError({ reason: "status", cause })),
138 );
139 });
140 socket.on("data", (chunk: string) => {
141 if (socket.bytesRead > 128 * 1024) {
142 finish(Effect.fail(new DevServerError({ reason: "status" })));
143 return;
144 }
145 frameBuffer += chunk;
146 const lines = frameBuffer.split("\n");
147 frameBuffer = lines.pop() ?? "";
148 try {
149 for (const line of lines) {
150 if (!line) continue;
151 const frame = decodeFrame(line);
152 output += frame.out ?? "";
153 if (frame.code !== undefined) {
154 finish(
155 frame.code === 0
156 ? Effect.succeed(output)
157 : Effect.fail(new DevServerError({ reason: "status" })),
158 );
159 return;
160 }
161 }
162 } catch (cause) {
163 finish(Effect.fail(new DevServerError({ reason: "status", cause })));
164 }
165 });
166 socket.once("close", () => finish(Effect.fail(new DevServerError({ reason: "status" }))));
167 return Effect.sync(() => {
168 socket.destroy();
169 });
170 }).pipe(
171 Effect.timeoutOrElse({
172 duration: "2 seconds",
173 orElse: () => Effect.fail(new DevServerError({ reason: "status" })),
174 }),
175 );
176
177 const probe = Effect.fn("DevServer.probe")(function* (root: string) {
178 const output = yield* request(root, ["status", "--json"]);
179 if (output === null) return null;
180 const runtime = yield* Effect.try(() => decodeStatus(output)).pipe(
181 Effect.mapError((cause) => new DevServerError({ reason: "status", cause })),
182 );
183 const [runtimeRoot, dataDir, expectedDataDir] = yield* Effect.all([
184 fs.realPath(runtime.root),
185 fs.realPath(runtime.dataDir),
186 fs.realPath(path.join(root, ".dev-tui")),
187 ]).pipe(Effect.mapError((cause) => new DevServerError({ reason: "status", cause })));
188 if (runtimeRoot !== root || dataDir !== expectedDataDir) {
189 return yield* new DevServerError({ reason: "status" });
190 }
191 return runtime;
192 });
193
194 const read = Effect.fn("DevServer.read")(function* (root: string) {
195 const [runtime, launchable] = yield* Effect.all([probe(root), canStart(root)]);
196 const services = Object.entries(runtime?.services ?? {}).map(([id, service]) => ({
197 id,
198 ...service,
199 }));
200 const client = runtime?.services.client;
201 let frontendUrl: string | null = null;
202 const clientUrl = client?.url;
203 if (client?.status === "ready" && clientUrl && !runtime?.stopping) {
204 const url = yield* Effect.try(() => new URL(clientUrl)).pipe(
205 Effect.mapError((cause) => new DevServerError({ reason: "status", cause })),
206 );
207 if (
208 url.protocol !== "http:" ||
209 !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
210 url.username ||
211 url.password
212 ) {
213 return yield* new DevServerError({ reason: "status" });
214 }
215 const reachable = yield* http.get(url.href).pipe(
216 Effect.map((response) => response.status >= 200 && response.status < 400),
217 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
218 Effect.timeout("3 seconds"),
219 Effect.scoped,
220 Effect.orElseSucceed(() => false),
221 );
222 if (reachable) frontendUrl = url.href;
223 }
224 return {
225 workspaceRoot: root,
226 canStart: launchable,
227 environment: runtime?.environment ?? null,
228 stopping: runtime?.stopping ?? false,
229 services,
230 frontendUrl,
231 } satisfies DevServerStatus;
232 });
233
234 const canonicalPath = (directory: string) =>
235 fs
236 .realPath(directory)
237 .pipe(
238 Effect.catch((cause) =>
239 cause.reason._tag === "NotFound"
240 ? Effect.succeed(null)
241 : Effect.fail(new DevServerError({ reason: "workspace", cause })),
242 ),
243 );
244
245 const unused = Effect.fn("DevServer.unused")(function* (threadId: ThreadId, root: string) {
246 const [snapshot, projectShells, targetRecords] = yield* Effect.all([
247 threads.getShellSnapshot(),
248 projects.listShells(),
249 threads.getThreadRecords(threadId, ["runs", "runtimeRequests"]),
250 ]).pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })));
251 const target = targetRecords.thread;
252 if (
253 !isParked(target) ||
254 targetRecords.runs.some((run) => liveRunStatuses.has(run.status)) ||
255 targetRecords.runtimeRequests.some((request) => request.status === "pending")
256 )
257 return false;
258 const candidates = [...snapshot.threads, ...snapshot.archivedThreads];
259 const owners = new Map(candidates.map((thread) => [thread.id, thread]));
260 const projectRoots = new Map(
261 projectShells.map((project) => [project.id, project.workspaceRoot]),
262 );
263 const targetDirectory = target.worktreePath ?? projectRoots.get(target.projectId);
264 if (targetDirectory === undefined || (yield* canonicalPath(targetDirectory)) !== root)
265 return false;
266 const canonicalRoots = new Map<string, string | null>();
267 for (const thread of candidates) {
268 const directory = thread.worktreePath ?? projectRoots.get(thread.projectId);
269 if (directory === undefined) return false;
270 if (!canonicalRoots.has(directory))
271 canonicalRoots.set(directory, yield* canonicalPath(directory));
272 const resolved = canonicalRoots.get(directory);
273 if (resolved == null) continue;
274 const relative = path.relative(root, resolved);
275 if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
276 continue;
277 if (
278 thread.activeRunId !== null ||
279 thread.activityRunStatus != null ||
280 liveRunStatuses.has(thread.status) ||
281 thread.pendingRuntimeRequest !== null ||
282 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
283 )
284 return false;
285 const records =
286 thread.id === threadId
287 ? targetRecords
288 : yield* threads
289 .getThreadRecords(thread.id, ["runs", "runtimeRequests"])
290 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })));
291 const current = records.thread;
292 const currentDirectory = current.worktreePath ?? projectRoots.get(current.projectId);
293 if (currentDirectory === undefined) return false;
294 const currentRoot = yield* canonicalPath(currentDirectory);
295 if (currentRoot === null) continue;
296 const currentRelative = path.relative(root, currentRoot);
297 if (
298 currentRelative === ".." ||
299 currentRelative.startsWith(`..${path.sep}`) ||
300 path.isAbsolute(currentRelative)
301 )
302 continue;
303 const ownerId =
304 current.lineage.rootThreadId === current.id
305 ? current.lineage.parentThreadId
306 : current.lineage.rootThreadId;
307 const nativeChild = isProviderNativeSubagentThread(current);
308 const owner =
309 nativeChild && ownerId !== null && (ownerId === threadId || owners.has(ownerId))
310 ? yield* threads.getThreadRecords(ownerId, []).pipe(
311 Effect.map((records) => records.thread),
312 Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }),
313 Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })),
314 )
315 : null;
316 if (!(nativeChild ? owner !== null && isParked(owner) : isParked(current))) return false;
317 if (
318 records.runs.some((run) => liveRunStatuses.has(run.status)) ||
319 records.runtimeRequests.some((request) => request.status === "pending")
320 )
321 return false;
322 }
323 return true;
324 });
325
326 const stopUnused = Effect.fn("DevServer.stopUnused")(function* (threadId: ThreadId) {
327 const records = yield* threads.getThreadRecords(threadId, []).pipe(
328 Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }),
329 Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })),
330 );
331 if (records === null) return;
332 const thread = records.thread;
333 const project = yield* projects
334 .getShell(thread.projectId)
335 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })));
336 if (Option.isNone(project)) return;
337 const root = yield* canonicalPath(thread.worktreePath ?? project.value.workspaceRoot);
338 if (root === null) return;
339 yield* withWorkspaceLease(
340 root,
341 Effect.gen(function* () {
342 const socketPath = path.join(root, ".dev-tui", "ipc.sock");
343 const socketInfo = yield* fs
344 .stat(socketPath)
345 .pipe(
346 Effect.catch((cause) =>
347 cause.reason._tag === "NotFound"
348 ? Effect.succeed(null)
349 : Effect.fail(new DevServerError({ reason: "status", cause })),
350 ),
351 );
352 if (socketInfo === null || !(yield* unused(threadId, root))) return;
353 const runtime = yield* probe(root);
354 if (runtime === null || runtime.stopping) return;
355 if (!(yield* unused(threadId, root))) return;
356 const canonicalSocket = yield* fs
357 .realPath(socketPath)
358 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "status", cause })));
359 if (canonicalSocket !== socketPath) return yield* new DevServerError({ reason: "status" });
360 const currentSocket = yield* fs
361 .stat(socketPath)
362 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "status", cause })));
363 if (
364 currentSocket.dev !== socketInfo.dev ||
365 !Option.isSome(socketInfo.ino) ||
366 !Option.isSome(currentSocket.ino) ||
367 currentSocket.ino.value !== socketInfo.ino.value
368 ) {
369 return yield* new DevServerError({ reason: "status" });
370 }
371 const accepted = yield* request(root, ["shutdown"]);
372 if (accepted !== null)
373 yield* Effect.logDebug("dev server shutdown requested", {
374 threadId,
375 workspaceRoot: root,
376 });
377 }),
378 );
379 });
380
381 return DevServer.of({
382 stopUnused,
383 status: (target) => workspace(target).pipe(Effect.flatMap(read)),
384 start: (target) =>
385 workspace(target).pipe(
386 Effect.flatMap((root) =>
387 withWorkspaceLease(
388 root,
389 Effect.gen(function* () {
390 const current = yield* read(root);
391 if (current.frontendUrl) return current;
392 if (!current.canStart) return yield* new DevServerError({ reason: "unsupported" });
393 const launched = yield* processRunner
394 .run({
395 command: "bash",
396 args: [path.join(root, "dev.sh"), "--headless"],
397 cwd: root,
398 env: { ...environment, DEV_TUI_DIR: devTuiDirectory },
399 timeout: "180 seconds",
400 maxOutputBytes: 128 * 1024,
401 outputMode: "truncate",
402 })
403 .pipe(Effect.mapError((cause) => new DevServerError({ reason: "launch", cause })));
404 if (launched.code !== 0) return yield* new DevServerError({ reason: "launch" });
405 const ready = yield* read(root);
406 if (!ready.frontendUrl) return yield* new DevServerError({ reason: "not-ready" });
407 return ready;
408 }),
409 ),
410 ),
411 ),
412 });
413});
414
415export const layer = Layer.effect(DevServer, make);
apps/server/src/ws.ts+5
......@@ -190,6 +190,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta
190190import * as PortScanner from "./preview/PortScanner.ts";
191191import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
192192import * as MergebotQueue from "./mergebot/MergebotQueue.ts";
193import * as DevServer from "./workspace/DevServer.ts";
193194import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
194195import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts";
195196import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
......@@ -1234,6 +1235,7 @@ const layerWsRpc = (
12341235 const serverSettings = yield* ServerSettings.ServerSettingsService;
12351236 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
12361237 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
1238 const devServer = yield* DevServer.DevServer;
12371239 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
12381240 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
12391241 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
......@@ -1675,6 +1677,7 @@ const layerWsRpc = (
16751677 otlpLogsEnabled: config.otlpLogsUrl !== undefined,
16761678 },
16771679 settings,
1680 devServerStatus: true,
16781681 ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}),
16791682 shellResumeCompletionMarker: true,
16801683 threadResumeCompletionMarker: true,
......@@ -2601,6 +2604,8 @@ const layerWsRpc = (
26012604 }),
26022605 ),
26032606 ),
2607 [WS_METHODS.devServerStatus]: (input) => devServer.status(input),
2608 [WS_METHODS.devServerStart]: (input) => devServer.start(input),
26042609 [WS_METHODS.projectsReadFile]: (input) =>
26052610 workspaceFileSystem.readFile(input).pipe(
26062611 Effect.mapError(
apps/web/src/components/chat/ChatComposer.tsx+17
......@@ -1157,6 +1157,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery";
11571157import { usePanelAnimationSettings } from "../../panelAnimations";
11581158import { useAtomCommand } from "../../state/use-atom-command";
11591159import { readEnvironmentScope } from "../../state/session";
1160import { ComposerDevServerStatus } from "./ComposerDevServerStatus";
11601161import { serverEnvironment } from "../../state/server";
11611162import type { ReviewCommentContext } from "../../reviewCommentContext";
11621163
......@@ -7851,6 +7852,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
78517852 }
78527853 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
78537854 >
7855 {routeKind === "server" ? (
7856 <ComposerDevServerStatus
7857 key={gitCwd}
7858 threadRef={routeThreadRef}
7859 target={{ _tag: "Thread", threadId: routeThreadRef.threadId }}
7860 />
7861 ) : activeProjectId && gitCwd ? (
7862 <ComposerDevServerStatus
7863 threadRef={routeThreadRef}
7864 target={{
7865 _tag: "Project",
7866 projectId: activeProjectId,
7867 workspacePath: gitCwd,
7868 }}
7869 />
7870 ) : null}
78547871 {showComposerAttachAction ? (
78557872 <>
78567873 <input
apps/web/src/components/chat/ComposerDevServerStatus.test.tsx created+307
......@@ -0,0 +1,307 @@
1import { EnvironmentId, ProjectId, ThreadId, type DevServerStatus } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/reactivity";
4import { act, create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 runtime: undefined as "server" | undefined,
9 connection: {
10 httpBaseUrl: "http://127.0.0.1:3773",
11 target: { _tag: "PrimaryConnectionTarget" },
12 },
13 phase: "connected" as "available" | "connecting" | "connected" | "reconnecting",
14 supported: true,
15 canRead: true,
16 canStart: true,
17 status: vi.fn(),
18 start: vi.fn(),
19 open: vi.fn(),
20 reveal: vi.fn(),
21 toast: vi.fn(),
22}));
23vi.mock("@effect/atom-react", () => ({
24 useAtomValue: (atom: string) =>
25 atom === "config"
26 ? { devServerStatus: state.supported }
27 : atom === "read"
28 ? state.canRead
29 : state.canStart,
30}));
31vi.mock("~/state/devServer", () => ({
32 devServerEnvironment: {
33 status: { id: "status", permissionAtom: () => "read" },
34 start: { id: "start", permissionAtom: () => "start-permission" },
35 },
36}));
37vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => "config" } }));
38vi.mock("~/state/preview", () => ({ previewEnvironment: { open: { id: "open" } } }));
39vi.mock("~/state/use-atom-command", () => ({
40 useAtomCommand: ({ id }: { id: "status" | "start" | "open" }) => state[id],
41}));
42vi.mock("~/state/environments", () => ({
43 useEnvironment: () => ({ connection: { phase: state.phase } }),
44}));
45vi.mock("~/state/session", () => ({
46 useEnvironmentScope: () => true,
47 readPreparedConnection: () => state.connection,
48}));
49vi.mock("~/env", () => ({ isElectron: true }));
50vi.mock("~/browser/previewRuntime", () => ({ previewRuntimeFor: () => state.runtime }));
51vi.mock("~/rightPanelStore", () => ({
52 useRightPanelStore: { getState: () => ({ openBrowser: state.reveal }) },
53}));
54vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open }));
55vi.mock("../ui/toast", () => ({
56 stackedThreadToast: (input: unknown) => input,
57 toastManager: { add: state.toast },
58}));
59vi.mock("../ui/button", () => ({
60 Button: ({ children, ...props }: React.ComponentProps<"button">) => (
61 <button {...props}>{children}</button>
62 ),
63}));
64
65import { ComposerDevServerStatus } from "./ComposerDevServerStatus";
66
67const threadRef = {
68 environmentId: EnvironmentId.make("host"),
69 threadId: ThreadId.make("thread-a"),
70};
71const target = { _tag: "Thread", threadId: threadRef.threadId } as const;
72const ready: DevServerStatus = {
73 workspaceRoot: "/workspace/a",
74 canStart: true,
75 environment: "local",
76 stopping: false,
77 services: [{ id: "client", status: "ready", url: "http://localhost:3001" }],
78 frontendUrl: "http://localhost:3001/",
79};
80const deferred = <T,>() => {
81 let resolve!: (value: T) => void;
82 const promise = new Promise<T>((complete) => {
83 resolve = complete;
84 });
85 return { promise, resolve };
86};
87let renderer: ReactTestRenderer | undefined;
88const mount = async () => {
89 await act(async () => {
90 renderer = create(<ComposerDevServerStatus threadRef={threadRef} target={target} />);
91 });
92};
93const click = () => renderer!.root.findByType("button").props.onClick();
94
95beforeEach(() => {
96 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
97 vi.stubGlobal("document", { visibilityState: "visible" });
98 vi.useFakeTimers();
99 state.runtime = undefined;
100 state.connection = {
101 httpBaseUrl: "http://127.0.0.1:3773",
102 target: { _tag: "PrimaryConnectionTarget" },
103 };
104 state.phase = "connected";
105 state.supported = true;
106 state.canRead = true;
107 state.canStart = true;
108 for (const mock of [state.status, state.start, state.open, state.reveal, state.toast])
109 mock.mockReset();
110 state.status.mockResolvedValue(AsyncResult.success(ready));
111 state.start.mockResolvedValue(AsyncResult.success(ready));
112 state.open.mockResolvedValue(AsyncResult.success({ tabId: "tab" }));
113});
114afterEach(async () => {
115 await act(async () => renderer?.unmount());
116 renderer = undefined;
117 vi.useRealTimers();
118 vi.unstubAllGlobals();
119});
120
121describe("composer dev server lifecycle", () => {
122 it("does not query old hosts or connections without destination read grants", async () => {
123 state.supported = false;
124 await mount();
125 expect(state.status).not.toHaveBeenCalled();
126 state.supported = true;
127 state.canRead = false;
128 await act(async () =>
129 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
130 );
131 expect(state.status).not.toHaveBeenCalled();
132 });
133
134 it("waits for connection readiness with cached capability and grants, then resumes on reconnect", async () => {
135 state.phase = "available";
136 await mount();
137 await act(async () => {
138 await vi.advanceTimersByTimeAsync(15000);
139 });
140 expect(state.status).not.toHaveBeenCalled();
141 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
142
143 state.phase = "connecting";
144 await act(async () =>
145 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
146 );
147 expect(state.status).not.toHaveBeenCalled();
148
149 state.phase = "connected";
150 await act(async () =>
151 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
152 );
153 expect(state.status).toHaveBeenCalledTimes(1);
154 expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Servers 1/1");
155
156 state.phase = "reconnecting";
157 await act(async () =>
158 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
159 );
160 expect(renderer!.root.findByType("button").props.disabled).toBe(true);
161 await act(async () => {
162 await vi.advanceTimersByTimeAsync(15000);
163 click();
164 });
165 expect(state.status).toHaveBeenCalledTimes(1);
166 expect(state.open).not.toHaveBeenCalled();
167
168 state.phase = "connected";
169 await act(async () =>
170 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
171 );
172 expect(state.status).toHaveBeenCalledTimes(2);
173 expect(renderer!.root.findByType("button").props.disabled).toBe(false);
174 await act(async () => {
175 await vi.advanceTimersByTimeAsync(5000);
176 });
177 expect(state.status).toHaveBeenCalledTimes(3);
178 });
179
180 it("starts the selected draft workspace once across repeated clicks", async () => {
181 const stopped = { ...ready, services: [], frontendUrl: null };
182 state.status.mockResolvedValue(AsyncResult.success(stopped));
183 const launch = deferred<ReturnType<typeof AsyncResult.success<DevServerStatus>>>();
184 state.start.mockReturnValue(launch.promise);
185 const draft = {
186 _tag: "Project",
187 projectId: ProjectId.make("project"),
188 workspacePath: "/workspace/selected",
189 } as const;
190 await act(async () => {
191 renderer = create(<ComposerDevServerStatus threadRef={threadRef} target={draft} />);
192 });
193 await act(async () => {
194 click();
195 click();
196 });
197 expect(state.start).toHaveBeenCalledTimes(1);
198 expect(state.start.mock.calls[0]?.[0]).toEqual({
199 environmentId: threadRef.environmentId,
200 input: draft,
201 });
202 expect(state.open).not.toHaveBeenCalled();
203 await act(async () => launch.resolve(AsyncResult.success(ready)));
204 expect(state.open).toHaveBeenCalledTimes(1);
205 expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab");
206 });
207
208 it("clears status immediately and discards an old launch when switching thread", async () => {
209 state.status.mockResolvedValue(AsyncResult.success({ ...ready, frontendUrl: null }));
210 const launch = deferred<ReturnType<typeof AsyncResult.success<DevServerStatus>>>();
211 state.start.mockReturnValue(launch.promise);
212 await mount();
213 await act(async () => click());
214 const nextRead = deferred<ReturnType<typeof AsyncResult.success<DevServerStatus>>>();
215 state.status.mockReturnValue(nextRead.promise);
216 const next = { ...threadRef, threadId: ThreadId.make("thread-b") };
217 await act(async () =>
218 renderer!.update(
219 <ComposerDevServerStatus
220 threadRef={next}
221 target={{ _tag: "Thread", threadId: next.threadId }}
222 />,
223 ),
224 );
225 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
226 await act(async () => launch.resolve(AsyncResult.success(ready)));
227 expect(state.open).not.toHaveBeenCalled();
228 expect(state.reveal).not.toHaveBeenCalled();
229 await act(async () =>
230 nextRead.resolve(AsyncResult.success({ ...ready, services: [], canStart: false })),
231 );
232 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
233 });
234
235 it("clears pending after a launch failure and allows a new attempt", async () => {
236 state.status.mockResolvedValue(AsyncResult.success({ ...ready, frontendUrl: null }));
237 state.start.mockResolvedValueOnce(
238 AsyncResult.failure(Cause.fail(new Error("Server did not start"))),
239 );
240 await mount();
241 await act(async () => click());
242 expect(state.toast).toHaveBeenCalledTimes(1);
243 expect(state.open).not.toHaveBeenCalled();
244 expect(renderer!.root.findByType("button").props.disabled).toBe(false);
245 await act(async () => click());
246 expect(state.start).toHaveBeenCalledTimes(2);
247 expect(state.open).toHaveBeenCalledTimes(1);
248 expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab");
249 });
250
251 it("maps a remote desktop frontend to the destination environment host", async () => {
252 state.connection = {
253 httpBaseUrl: "http://100.65.180.100:3773",
254 target: { _tag: "RemoteConnectionTarget" },
255 };
256 await mount();
257 await act(async () => click());
258 expect(state.open).toHaveBeenCalledWith(
259 expect.objectContaining({ threadRef, url: "http://100.65.180.100:3001/" }),
260 );
261 state.runtime = "server";
262 await act(async () => click());
263 expect(state.open).toHaveBeenLastCalledWith(
264 expect.objectContaining({ threadRef, url: ready.frontendUrl }),
265 );
266 });
267
268 it("resumes bounded polling after a successful explicit retry and pauses on another error", async () => {
269 state.status.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("unavailable"))));
270 await mount();
271 await act(async () => {
272 await vi.advanceTimersByTimeAsync(15000);
273 });
274 expect(state.status).toHaveBeenCalledTimes(1);
275 state.phase = "reconnecting";
276 await act(async () =>
277 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
278 );
279 state.phase = "connected";
280 await act(async () =>
281 renderer!.update(<ComposerDevServerStatus threadRef={threadRef} target={target} />),
282 );
283 await act(async () => {
284 await vi.advanceTimersByTimeAsync(15000);
285 });
286 expect(state.status).toHaveBeenCalledTimes(1);
287 state.status.mockResolvedValue(AsyncResult.success(ready));
288 await act(async () => click());
289 expect(state.open).toHaveBeenCalledTimes(1);
290 expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab");
291 state.status.mockResolvedValue(
292 AsyncResult.success({ ...ready, services: [], frontendUrl: null }),
293 );
294 const reads = state.status.mock.calls.length;
295 await act(async () => {
296 await vi.advanceTimersByTimeAsync(5000);
297 });
298 expect(state.status).toHaveBeenCalledTimes(reads + 1);
299 expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Start frontend");
300 state.status.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("unavailable"))));
301 await act(async () => {
302 await vi.advanceTimersByTimeAsync(15000);
303 });
304 expect(state.status).toHaveBeenCalledTimes(reads + 2);
305 expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Servers unavailable");
306 });
307});
apps/web/src/components/chat/ComposerDevServerStatus.tsx created+156
......@@ -0,0 +1,156 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 AuthPreviewOperateScope,
4 type DevServerStatus,
5 type DevServerTarget,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { GlobeIcon } from "lucide-react";
10import { useEffect, useEffectEvent, useRef, useState } from "react";
11
12import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
13import { previewRuntimeFor } from "~/browser/previewRuntime";
14import { useRightPanelStore } from "~/rightPanelStore";
15import { devServerEnvironment } from "~/state/devServer";
16import { useEnvironment } from "~/state/environments";
17import { previewEnvironment } from "~/state/preview";
18import { serverEnvironment } from "~/state/server";
19import { useAtomCommand } from "~/state/use-atom-command";
20import { useEnvironmentScope } from "~/state/session";
21import { openPreviewSession } from "../preview/openPreviewSession";
22import { Button } from "../ui/button";
23import { stackedThreadToast, toastManager } from "../ui/toast";
24
25interface Props {
26 threadRef: ScopedThreadRef;
27 target: DevServerTarget;
28}
29
30export function ComposerDevServerStatus(props: Props) {
31 const config = useAtomValue(serverEnvironment.configValueAtom(props.threadRef.environmentId));
32 const canRead = useAtomValue(
33 devServerEnvironment.status.permissionAtom(props.threadRef.environmentId),
34 );
35 if (!config?.devServerStatus || !canRead) return null;
36 return <DevServerButton key={JSON.stringify([props.threadRef, props.target])} {...props} />;
37}
38
39function DevServerButton({ threadRef, target }: Props) {
40 const connected = useEnvironment(threadRef.environmentId)?.connection.phase === "connected";
41 const [status, setStatus] = useState<DevServerStatus | null>(null);
42 const [pending, setPending] = useState(false);
43 const [unavailable, setUnavailable] = useState(false);
44 const live = useRef(true);
45 const busy = useRef(false);
46 const load = useAtomCommand(devServerEnvironment.status, { reportFailure: false });
47 const start = useAtomCommand(devServerEnvironment.start, { reportFailure: false });
48 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
49 const canPreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope);
50 const canStart = useAtomValue(devServerEnvironment.start.permissionAtom(threadRef.environmentId));
51 const poll = useEffectEvent(async () => {
52 if (busy.current || document.visibilityState === "hidden") return true;
53 const result = await load({ environmentId: threadRef.environmentId, input: target });
54 if (!live.current || busy.current) return true;
55 if (result._tag === "Failure") {
56 setStatus(null);
57 setUnavailable(true);
58 return false;
59 }
60 setStatus(result.value);
61 setUnavailable(false);
62 return true;
63 });
64
65 useEffect(() => {
66 live.current = true;
67 let active = true;
68 let timer: ReturnType<typeof setTimeout> | undefined;
69 const refresh = async () => {
70 if (!active) return;
71 if ((await poll()) && active) timer = setTimeout(() => void refresh(), 5000);
72 };
73 if (connected && !unavailable) void refresh();
74 return () => {
75 active = false;
76 live.current = false;
77 clearTimeout(timer);
78 };
79 }, [connected, unavailable]);
80
81 const open = async () => {
82 if (!connected || busy.current) return;
83 busy.current = true;
84 setPending(true);
85 try {
86 let result = await load({ environmentId: threadRef.environmentId, input: target });
87 if (!live.current) return;
88 if (result._tag === "Failure") throw result.cause;
89 if (!result.value.frontendUrl && result.value.canStart) {
90 result = await start({ environmentId: threadRef.environmentId, input: target });
91 if (!live.current) return;
92 if (result._tag === "Failure") throw result.cause;
93 }
94 setStatus(result.value);
95 setUnavailable(false);
96 const frontendUrl = result.value.frontendUrl;
97 if (!frontendUrl)
98 throw new Error("Frontend unavailable. Check the dev server and try again.");
99 const url =
100 previewRuntimeFor(threadRef.environmentId) === "server"
101 ? frontendUrl
102 : resolveDiscoveredServerUrl(threadRef.environmentId, frontendUrl);
103 const opened = await openPreviewSession({ threadRef, url, openPreview });
104 if (!live.current) return;
105 if (opened._tag === "Failure") throw opened.cause;
106 useRightPanelStore.getState().openBrowser(threadRef, opened.value.tabId);
107 } catch (cause) {
108 if (!live.current) return;
109 const error = Cause.isCause(cause) ? Cause.squash(cause) : cause;
110 setUnavailable(true);
111 toastManager.add(
112 stackedThreadToast({
113 type: "error",
114 title: "Could not open frontend",
115 description:
116 error instanceof Error ? error.message : "Check the dev server and try again.",
117 }),
118 );
119 } finally {
120 busy.current = false;
121 if (live.current) setPending(false);
122 }
123 };
124
125 if (!status && !unavailable) return null;
126 if (status && !status.canStart && status.services.length === 0) return null;
127 const ready = status?.services.filter((service) => service.status === "ready").length ?? 0;
128 const total = status?.services.length ?? 0;
129 const label = pending
130 ? "Opening…"
131 : unavailable
132 ? "Servers unavailable"
133 : status?.stopping
134 ? "Stopping…"
135 : total > 0
136 ? `Servers ${ready}/${total}`
137 : "Start frontend";
138 const canOpen = status?.frontendUrl || (status?.canStart && canStart) || unavailable;
139 const detail = status?.services.map((service) => `${service.id}: ${service.status}`).join(", ");
140
141 return (
142 <Button
143 variant="ghost-muted"
144 size="compact"
145 disabled={!connected || pending || status?.stopping || !canOpen || !canPreview}
146 aria-label={label}
147 aria-busy={pending}
148 title={detail || undefined}
149 onPointerDown={(event) => event.preventDefault()}
150 onClick={() => void open()}
151 >
152 <GlobeIcon aria-hidden="true" />
153 <span role="status">{label}</span>
154 </Button>
155 );
156}
apps/web/src/state/devServer.ts created+14
......@@ -0,0 +1,14 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime";
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const devServerEnvironment = {
6 status: createEnvironmentRpcCommand(connectionAtomRuntime, {
7 label: "environment-data:dev-server:status",
8 tag: WS_METHODS.devServerStatus,
9 }),
10 start: createEnvironmentRpcCommand(connectionAtomRuntime, {
11 label: "environment-data:dev-server:start",
12 tag: WS_METHODS.devServerStart,
13 }),
14};
packages/client-runtime/src/state/commandPermissions.test.ts+36
......@@ -8,6 +8,9 @@ import { describe, expect, it } from "@effect/vitest";
88import { vi } from "vite-plus/test";
99import {
1010 AuthOrchestrationOperateScope,
11 AuthTerminalOperateScope,
12 AuthFilesystemReadScope,
13 AuthOrchestrationReadScope,
1114 AuthSettingsWriteScope,
1215 AuthSourceControlWriteScope,
1316 ThreadId,
......@@ -83,6 +86,39 @@ describe("command permissions", () => {
8386 ),
8487 );
8588
89 it.effect("requires destination read grants for dev status and terminal authority to start", () =>
90 Effect.scoped(
91 Effect.gen(function* () {
92 const registry = yield* setup;
93 const status = createCommandPermissions(runtime, WS_METHODS.devServerStatus);
94 const start = createCommandPermissions(runtime, WS_METHODS.devServerStart);
95 const readGrant = {
96 ...grant(false),
97 scopes: [AuthFilesystemReadScope, AuthOrchestrationReadScope],
98 permissions: [AuthFilesystemReadScope, AuthOrchestrationReadScope],
99 };
100 registry.set(sessions(env), AsyncResult.success(readGrant));
101 expect(registry.get(status.permissionAtom(env))).toBe(true);
102 expect(registry.get(start.permissionAtom(env))).toBe(false);
103 expect((yield* start.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
104 "EnvironmentAuthorizationError",
105 );
106 registry.set(
107 sessions(env),
108 AsyncResult.success({
109 ...readGrant,
110 scopes: [...readGrant.scopes, AuthTerminalOperateScope],
111 permissions: [...readGrant.permissions, AuthTerminalOperateScope],
112 }),
113 );
114 yield* start.authorize(registry, env);
115 registry.set(sessions(other), AsyncResult.success(grant(false)));
116 expect(registry.get(status.permissionAtom(other))).toBe(false);
117 expect(registry.get(start.permissionAtom(other))).toBe(false);
118 }),
119 ),
120 );
121
86122 it.effect("requires the destination settings grant to run storage cleanup", () =>
87123 Effect.scoped(
88124 Effect.gen(function* () {
packages/contracts/src/clientRpcPermissions.ts+7
......@@ -2,6 +2,9 @@ import * as Schema from "effect/Schema";
22import { GitPreparePullRequestThreadInput } from "./git.ts";
33import {
44 AuthOrchestrationOperateScope,
5 AuthTerminalOperateScope,
6 AuthFilesystemReadScope,
7 AuthOrchestrationReadScope,
58 AuthSettingsWriteScope,
69 AuthSourceControlWriteScope,
710 type AuthEnvironmentScope,
......@@ -12,6 +15,8 @@ import { WS_METHODS } from "./rpc.ts";
1215/** Incremental client enforcement; the server still authorizes every request. */
1316export const CLIENT_GUARDED_RPC_SCOPES = {
1417 [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope,
18 [WS_METHODS.devServerStatus]: AuthFilesystemReadScope,
19 [WS_METHODS.devServerStart]: AuthTerminalOperateScope,
1520 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,
1621 // Endpoint checks exist to configure exports, so they need the same grant as saving them.
1722 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
......@@ -57,6 +62,8 @@ export function clientRpcRequiredScopes(
5762 method: string,
5863 input: unknown,
5964): readonly AuthEnvironmentScope[] {
65 if (method === WS_METHODS.devServerStatus)
66 return [AuthFilesystemReadScope, AuthOrchestrationReadScope];
6067 if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) {
6168 const payload = decodePrepareThread(input);
6269 if (payload.mode === "worktree" && payload.threadId !== undefined)
packages/contracts/src/devServer.ts created+44
......@@ -0,0 +1,44 @@
1import * as Schema from "effect/Schema";
2import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
3
4export const DevServerTarget = Schema.Union([
5 Schema.Struct({ _tag: Schema.Literal("Thread"), threadId: ThreadId }),
6 Schema.Struct({
7 _tag: Schema.Literal("Project"),
8 projectId: ProjectId,
9 workspacePath: TrimmedNonEmptyString,
10 }),
11]);
12export type DevServerTarget = typeof DevServerTarget.Type;
13
14export const DevServerStatus = Schema.Struct({
15 workspaceRoot: Schema.String,
16 canStart: Schema.Boolean,
17 environment: Schema.NullOr(Schema.String),
18 stopping: Schema.Boolean,
19 services: Schema.Array(
20 Schema.Struct({ id: Schema.String, status: Schema.String, url: Schema.NullOr(Schema.String) }),
21 ),
22 frontendUrl: Schema.NullOr(Schema.String),
23});
24export type DevServerStatus = typeof DevServerStatus.Type;
25
26export class DevServerError extends Schema.TaggedError<DevServerError>()("DevServerError", {
27 reason: Schema.Literals(["workspace", "status", "unsupported", "launch", "not-ready"]),
28 cause: Schema.optional(Schema.Defect()),
29}) {
30 override get message(): string {
31 switch (this.reason) {
32 case "workspace":
33 return "Workspace unavailable. Open another thread and try again.";
34 case "status":
35 return "Server status unavailable. Check the dev server and try again.";
36 case "unsupported":
37 return "Start this workspace's dev server in a terminal, then try again.";
38 case "launch":
39 return "Server did not start. Check .dev-tui/server.log and try again.";
40 case "not-ready":
41 return "Frontend unavailable. Check the dev server and try again.";
42 }
43 }
44}
packages/contracts/src/index.ts+1
......@@ -65,6 +65,7 @@ export * from "./rpc.ts";
6565export * from "./worktreeSetup.ts";
6666export * from "./secretRequest.ts";
6767export * from "./clientRpcPermissions.ts";
68export * from "./devServer.ts";
6869export * from "./mergebot.ts";
6970export * from "./scriptProgress.ts";
7071export * from "./desktopTerminal.ts";
packages/contracts/src/rpc.ts+17
......@@ -1,4 +1,5 @@
11import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts";
2import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts";
23import {
34 UiSettingsProfile,
45 UiSettingsPatchInput,
......@@ -397,6 +398,9 @@ export const WS_METHODS = {
397398 projectsEnsureScratch: "projects.ensureScratch",
398399 projectsCreateNew: "projects.createNew",
399400
401 devServerStatus: "devServer.status",
402 devServerStart: "devServer.start",
403
400404 // Shell methods
401405 shellOpenInEditor: "shell.openInEditor",
402406
......@@ -1277,6 +1281,17 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, {
12771281 error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]),
12781282});
12791283
1284const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, {
1285 payload: DevServerTarget,
1286 success: DevServerStatus,
1287 error: Schema.Union([DevServerError, EnvironmentAuthorizationError]),
1288});
1289const WsDevServerStartRpc = Rpc.make(WS_METHODS.devServerStart, {
1290 payload: DevServerTarget,
1291 success: DevServerStatus,
1292 error: Schema.Union([DevServerError, EnvironmentAuthorizationError]),
1293});
1294
12801295const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, {
12811296 payload: ProjectReadFileInput,
12821297 success: ProjectReadFileResult,
......@@ -2064,6 +2079,8 @@ export const WsRpcGroup = RpcGroup.make(
20642079 WsProjectsListEntriesRpc,
20652080 WsProjectsReadFileRpc,
20662081 WsProjectsWatchRpc,
2082 WsDevServerStatusRpc,
2083 WsDevServerStartRpc,
20672084 WsProjectsSearchContentsRpc,
20682085 WsProjectsSearchEntriesRpc,
20692086 WsProjectsEnsureScratchRpc,
packages/contracts/src/server.ts+1
......@@ -674,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({
674674export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type;
675675
676676export const ServerConfig = Schema.Struct({
677 devServerStatus: Schema.optionalKey(Schema.Boolean),
677678 mergebot: Schema.optionalKey(MergebotConfiguration),
678679 environment: ExecutionEnvironmentDescriptor,
679680 auth: ServerAuthDescriptor,