From b3a01d86bc6c4c62f7469c43b02aa53b97fe3f44 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 13 Oct 2026 10:14:41 -0700 Subject: [PATCH] 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 --- apps/server/src/auth/RpcAuthorization.test.ts | 64 ++ .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/server.ts | 8 + apps/server/src/workspace/DevServer.test.ts | 705 ++++++++++++++++++ apps/server/src/workspace/DevServer.ts | 415 +++++++++++ apps/server/src/ws.ts | 5 + apps/web/src/components/chat/ChatComposer.tsx | 17 + .../chat/ComposerDevServerStatus.test.tsx | 307 ++++++++ .../chat/ComposerDevServerStatus.tsx | 156 ++++ apps/web/src/state/devServer.ts | 14 + .../src/state/commandPermissions.test.ts | 36 + .../contracts/src/clientRpcPermissions.ts | 7 + packages/contracts/src/devServer.ts | 44 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 17 + packages/contracts/src/server.ts | 1 + 16 files changed, 1799 insertions(+) create mode 100644 apps/server/src/workspace/DevServer.test.ts create mode 100644 apps/server/src/workspace/DevServer.ts create mode 100644 apps/web/src/components/chat/ComposerDevServerStatus.test.tsx create mode 100644 apps/web/src/components/chat/ComposerDevServerStatus.tsx create mode 100644 apps/web/src/state/devServer.ts create mode 100644 packages/contracts/src/devServer.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 92d1ec94d98b15a786c1d139f7d667199790fd48..174b3dd56b140f5946f5ee28b51a0fac39bf2876 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -238,6 +238,70 @@ it("requires operate permission for tool updates even alongside a read-only chec expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); }); +describe("dev server RPC authority", () => { + const tested = [WS_METHODS.devServerStatus, WS_METHODS.devServerStart] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + it.effect.each([ + { + method: WS_METHODS.devServerStart, + scopes: [AuthFilesystemReadScope, AuthOrchestrationReadScope], + missing: AuthTerminalOperateScope, + }, + { + method: WS_METHODS.devServerStatus, + scopes: [AuthOrchestrationReadScope], + missing: AuthFilesystemReadScope, + }, + { + method: WS_METHODS.devServerStatus, + scopes: [AuthFilesystemReadScope], + missing: AuthOrchestrationReadScope, + }, + ])("rejects $method without $missing before its handler runs", ({ method, scopes, missing }) => + Effect.scoped( + Effect.gen(function* () { + let handled = false; + const response = { + workspaceRoot: "/workspace", + canStart: false, + environment: null, + stopping: false, + services: [], + frontendUrl: null, + }; + const handler = () => + Effect.sync(() => { + handled = true; + return response; + }); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.devServerStatus, handler), + group.toLayerHandler(WS_METHODS.devServerStart, handler), + RpcAuthorization.layer(scopes), + ), + ), + ); + const error = yield* client[method]({ + _tag: "Thread", + threadId: ThreadId.make("thread"), + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredPermission: missing, + }); + expect(handled).toBe(false); + }), + ), + ); +}); + describe("RPC scope middleware", () => { const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const; const group = WsRpcGroup.omit( diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index ba4c9f38adbecd62ef55ad47e4a28aa99f9d445e..4e13b92614ef17310726c71754c5e135add5e63b 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -141,6 +141,8 @@ const RPC_AGGREGATES = { [WS_METHODS.projectCloneRetry]: "source-control", [WS_METHODS.subscribeProjectClones]: "source-control", [WS_METHODS.projectsListEntries]: "workspace", + [WS_METHODS.devServerStatus]: "workspace", + [WS_METHODS.devServerStart]: "workspace", [WS_METHODS.projectsWatch]: "workspace", [WS_METHODS.projectsReadFile]: "workspace", [WS_METHODS.projectsSearchContents]: "workspace", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 08dd58ddce40c9fdf2717d4e5a4ed8fe14175834..841fdfcb47be5d252d8f835f900a470d28c6a33b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -118,6 +118,7 @@ import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; +import * as DevServer from "./workspace/DevServer.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -741,6 +742,13 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase ), ), ), + Layer.provideMerge( + DevServer.layer.pipe( + Layer.provide(ProjectionStoreV2.layer), + Layer.provide(ProjectStore.layer), + Layer.provide(ProcessRunner.layer), + ), + ), Layer.provideMerge(layerWorkspace), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), diff --git a/apps/server/src/workspace/DevServer.test.ts b/apps/server/src/workspace/DevServer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2cb75757033f3c2dba741c0dd44c663f4100bfc --- /dev/null +++ b/apps/server/src/workspace/DevServer.test.ts @@ -0,0 +1,705 @@ +// @effect-diagnostics nodeBuiltinImport:off - Creates isolated Unix socket fixtures outside the service. +import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ThreadId, + DevServerError, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as DevServer from "./DevServer.ts"; + +const target = { _tag: "Thread", threadId: ThreadId.make("thread") } as const; +let home: string; +let root: string; +let projectRoot: string; +let server: NodeNet.Server | undefined; +let state: { + root: string; + dataDir: string; + environment: string; + stopping: boolean; + services: Record; +}; +let malformed = false; +let httpStatus = 200; +const launches = vi.fn(); +const requestedUrls: string[] = []; +const commands: ReadonlyArray[] = []; +let statusObserved: (() => void) | undefined; +let shutdownCode = 0; +let snapshotReads = 0; +let recordsObserved: ((threadId: ThreadId) => void) | undefined; +type ThreadFields = Pick< + OrchestrationV2ThreadShell, + | "id" + | "projectId" + | "worktreePath" + | "archivedAt" + | "settledOverride" + | "settledAt" + | "activeRunId" + | "activityRunStatus" + | "status" + | "pendingRuntimeRequest" + | "pendingBackgroundTasks" + | "deletedAt" + | "lineage" + | "creationSource" +>; +type Thread = { -readonly [K in keyof ThreadFields]: ThreadFields[K] }; +let threadRefs: Thread[]; +let projectRefs: { id: ProjectId; workspaceRoot: string }[]; +let runStatuses: Record; +let pendingRequests: Record; +function thread(overrides: Partial = {}): Thread { + return { + id: target.threadId, + projectId: ProjectId.make("project"), + worktreePath: root, + archivedAt: null, + deletedAt: null, + creationSource: "web", + lineage: { rootThreadId: target.threadId, parentThreadId: null, relationshipToParent: null }, + settledOverride: "settled", + settledAt: null, + activeRunId: null, + activityRunStatus: null, + status: "completed", + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + ...overrides, + }; +} + +beforeEach(async () => { + home = await NodeFSP.realpath(await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ds-"))); + root = NodePath.join(home, "wt"); + projectRoot = NodePath.join(home, "project"); + await NodeFSP.mkdir(NodePath.join(root, ".dev-tui"), { recursive: true }); + await NodeFSP.mkdir(projectRoot); + state = { + root, + dataDir: NodePath.join(root, ".dev-tui"), + environment: "local", + stopping: false, + services: {}, + }; + malformed = false; + httpStatus = 200; + requestedUrls.length = 0; + launches.mockReset(); + commands.length = 0; + statusObserved = undefined; + shutdownCode = 0; + snapshotReads = 0; + recordsObserved = undefined; + threadRefs = [thread()]; + projectRefs = [{ id: ProjectId.make("project"), workspaceRoot: projectRoot }]; + runStatuses = {}; + pendingRequests = {}; +}); + +afterEach(async () => { + if (server) await new Promise((resolve) => server!.close(() => resolve())); + server = undefined; + await NodeFSP.rm(home, { recursive: true, force: true }); +}); + +async function socket(socketPath = NodePath.join(root, ".dev-tui", "ipc.sock")) { + server = NodeNet.createServer((connection) => { + connection.once("data", (command) => { + const args = JSON.parse(command.toString()) as string[]; + commands.push(args); + if (args[0] === "shutdown") { + connection.end( + JSON.stringify({ out: "ok\n" }) + "\n" + JSON.stringify({ code: shutdownCode }) + "\n", + ); + return; + } + expect(args).toEqual(["status", "--json"]); + statusObserved?.(); + if (malformed) { + connection.end("garbage\n"); + return; + } + const output = JSON.stringify(state); + // Both frames and the JSON payload may cross socket chunk boundaries. + connection.write(JSON.stringify({ out: output.slice(0, 10) }) + "\n"); + const last = + JSON.stringify({ out: output.slice(10) }) + "\n" + JSON.stringify({ code: 0 }) + "\n"; + connection.write(last.slice(0, 5)); + connection.end(last.slice(5)); + }); + }); + await new Promise((resolve, reject) => { + server!.once("error", reject); + server!.listen(socketPath, resolve); + }); +} + +async function workProject() { + await NodeFSP.mkdir(NodePath.join(home, "dev", "dev-tui"), { recursive: true }); + await Promise.all([ + NodeFSP.writeFile(NodePath.join(root, "dev.sh"), ""), + NodeFSP.writeFile(NodePath.join(root, "dev.config.ts"), ""), + NodeFSP.writeFile(NodePath.join(home, "dev", "dev-tui", "dev.sh"), ""), + ]); +} + +const layer = () => + DevServer.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcess.HomeDirectory, home), + Layer.succeed(HostProcess.Environment, { PATH: "/test/bin" }), + Layer.succeed(ProjectStore.ProjectStoreV2, { + get: (id: ProjectId) => + Effect.sync(() => Option.fromNullishOr(projectRefs.find((p) => p.id === id))), + getShell: (id: ProjectId) => + Effect.sync(() => Option.fromNullishOr(projectRefs.find((p) => p.id === id))), + listShells: () => Effect.sync(() => projectRefs.slice()), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Layer.succeed(ProjectionStore.ProjectionStoreV2, { + getThread: (id: ThreadId) => Effect.sync(() => threadRefs.find((t) => t.id === id)), + getShellSnapshot: () => + Effect.sync(() => { + snapshotReads++; + return { + threads: threadRefs.filter((t) => t.archivedAt === null && t.deletedAt === null), + archivedThreads: threadRefs.filter( + (t) => t.archivedAt !== null && t.deletedAt === null, + ), + }; + }), + getThreadRecords: (id: ThreadId) => + Effect.suspend(() => { + recordsObserved?.(id); + const found = threadRefs.find((t) => t.id === id); + return found === undefined + ? Effect.fail( + new ProjectionStore.ProjectionStoreThreadNotFoundError({ threadId: id }), + ) + : Effect.succeed({ + thread: found, + runs: (runStatuses[id] ?? []).map((status) => ({ status })), + runtimeRequests: pendingRequests[id] ? [{ status: "pending" }] : [], + }); + }), + } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), + Layer.succeed(ProcessRunner.ProcessRunner, { run: launches }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requestedUrls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: httpStatus })), + ); + }), + ), + ), + ), + ); +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(layer())); +const launched = { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +} as const; + +describe("DevServer", () => { + it.effect("reads this thread's worktree and requires a healthy HTTP frontend", () => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + state.services = { + client: { status: "ready", url: "http://localhost:3210" }, + api: { status: "crashed", url: null }, + }; + const status = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.status(target)), + ); + expect(status).toMatchObject({ + workspaceRoot: root, + canStart: false, + frontendUrl: "http://localhost:3210/", + }); + expect(status.services).toHaveLength(2); + expect(requestedUrls).toEqual(["http://localhost:3210/"]); + httpStatus = 503; + const unavailable = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.status(target)), + ); + expect(unavailable.frontendUrl).toBeNull(); + }), + ); + + it.effect("uses the selected pre-send workspace rather than the project's root", () => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + state.services.client = { status: "ready", url: "http://localhost:3210" }; + const draft = { + _tag: "Project", + projectId: ProjectId.make("project"), + workspacePath: root, + } as const; + const status = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.status(draft)), + ); + expect(status.workspaceRoot).toBe(root); + expect(status.frontendUrl).toBe("http://localhost:3210/"); + }), + ); + + it.effect("recognizes stopped work project without launching during a status read", () => + Effect.gen(function* () { + yield* Effect.promise(workProject); + const status = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.status(target)), + ); + expect(status).toMatchObject({ canStart: true, services: [], frontendUrl: null }); + expect(launches).not.toHaveBeenCalled(); + }), + ); + + it.effect("never starts arbitrary workspaces or executes status text", () => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + state.services.client = { status: "starting", url: "bash /tmp/execute-me" }; + const error = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.start(target)), + ).pipe(Effect.flip); + expect(error).toMatchObject({ reason: "unsupported" }); + expect(launches).not.toHaveBeenCalled(); + expect(requestedUrls).toEqual([]); + }), + ); + + it.effect.each(["wrong-root", "remote-url", "malformed", "stopping"])( + "rejects or withholds a frontend for %s", + (scenario) => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + state.services.client = { status: "ready", url: "http://localhost:3210" }; + if (scenario === "wrong-root") state.root = projectRoot; + if (scenario === "remote-url") state.services.client.url = "https://example.com/"; + if (scenario === "malformed") malformed = true; + if (scenario === "stopping") { + state.stopping = true; + const status = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.status(target)), + ); + expect(status.frontendUrl).toBeNull(); + } else { + const error = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.status(target)), + ).pipe(Effect.flip); + expect(error).toMatchObject({ reason: "status" }); + } + expect(requestedUrls).toEqual([]); + }), + ); + + it.effect("deduplicates concurrent launch requests and uses the current detached launcher", () => + Effect.gen(function* () { + yield* Effect.promise(workProject); + launches.mockImplementation(() => + Effect.promise(async () => { + await socket(); + state.services.client = { status: "ready", url: "http://localhost:3210" }; + return launched; + }), + ); + const results = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => + Effect.all([service.start(target), service.start(target)], { concurrency: "unbounded" }), + ), + ); + expect(results.map((status) => status.frontendUrl)).toEqual([ + "http://localhost:3210/", + "http://localhost:3210/", + ]); + expect(launches).toHaveBeenCalledTimes(1); + expect(launches).toHaveBeenCalledWith( + expect.objectContaining({ + command: "bash", + args: [NodePath.join(root, "dev.sh"), "--headless"], + cwd: root, + env: { PATH: "/test/bin", DEV_TUI_DIR: NodePath.join(home, "dev", "dev-tui") }, + }), + ); + }), + ); + + it.effect("returns a launch error when the detached launcher reports startup failure", () => + Effect.gen(function* () { + yield* Effect.promise(workProject); + launches.mockImplementation(() => + Effect.succeed({ ...launched, code: ChildProcessSpawner.ExitCode(1) }), + ); + const error = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.start(target)), + ).pipe(Effect.flip); + expect(error).toMatchObject({ reason: "launch" }); + expect(requestedUrls).toEqual([]); + }), + ); + + it.effect("does not open when launcher succeeds before frontend HTTP readiness", () => + Effect.gen(function* () { + yield* Effect.promise(workProject); + yield* Effect.promise(() => socket()); + launches.mockImplementation(() => + Effect.sync(() => { + state.services.client = { status: "ready", url: "http://localhost:3210" }; + return launched; + }), + ); + httpStatus = 503; + const error = yield* run( + Effect.flatMap(DevServer.DevServer, (service) => service.start(target)), + ).pipe(Effect.flip); + expect(error).toMatchObject({ reason: "not-ready" }); + }), + ); + + describe("stopUnused", () => { + const stop = () => + run(Effect.flatMap(DevServer.DevServer, (service) => service.stopUnused(target.threadId))); + + it.effect( + "requests shutdown only through the verified default socket, without launching or deleting a workspace", + () => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"], ["shutdown"]]); + expect(launches).not.toHaveBeenCalled(); + expect(requestedUrls).toEqual([]); + expect(yield* Effect.promise(() => NodeFSP.stat(root))).toBeDefined(); + // Acknowledgement is not an exit: a still-running fixture can receive a retry. + yield* stop(); + expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(2); + }), + ); + + it.effect("stops a project-root runtime when the thread has no separate worktree", () => + Effect.gen(function* () { + projectRefs[0]!.workspaceRoot = root; + threadRefs[0]!.worktreePath = null; + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"], ["shutdown"]]); + }), + ); + + it.effect("does not fall back to the project runtime when a managed worktree is gone", () => + Effect.gen(function* () { + projectRefs[0]!.workspaceRoot = root; + threadRefs[0]!.worktreePath = NodePath.join(home, "missing-worktree"); + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([]); + }), + ); + + it.effect("missing sockets and missing threads are idempotent", () => + Effect.gen(function* () { + yield* stop(); + expect(snapshotReads).toBe(0); + threadRefs.length = 0; + yield* stop(); + expect(commands).toEqual([]); + }), + ); + + it.effect.each(["active-target", "active-peer", "other-project", "descendant", "symlink"])( + "protects %s", + (scenario) => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + if (scenario === "active-target") { + threadRefs[0]!.settledOverride = "active"; + } else { + const peer = thread({ id: ThreadId.make("peer"), settledOverride: "active" }); + if (scenario === "other-project") { + peer.projectId = ProjectId.make("another-project"); + peer.worktreePath = null; + projectRefs.push({ id: peer.projectId, workspaceRoot: root }); + } + if (scenario === "descendant") { + peer.worktreePath = NodePath.join(root, "nested"); + if (scenario === "descendant") + yield* Effect.promise(() => NodeFSP.mkdir(peer.worktreePath!)); + } + if (scenario === "symlink") { + peer.worktreePath = NodePath.join(home, "alias"); + yield* Effect.promise(() => NodeFSP.symlink(root, peer.worktreePath!)); + } + threadRefs.push(peer); + } + yield* stop(); + expect(commands).toEqual([]); + }), + ); + + it.effect("a missing peer workspace cannot host or use this runtime", () => + Effect.gen(function* () { + threadRefs.push( + thread({ + id: ThreadId.make("missing-peer"), + worktreePath: NodePath.join(root, "gone"), + settledOverride: "active", + }), + ); + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"], ["shutdown"]]); + }), + ); + + it.effect("ignores E2E and custom data directories when the default socket is absent", () => + Effect.gen(function* () { + const directory = NodePath.join(root, ".dev-tui-e2e"); + yield* Effect.promise(() => NodeFSP.mkdir(directory)); + yield* Effect.promise(() => socket(NodePath.join(directory, "ipc.sock"))); + yield* stop(); + expect(commands).toEqual([]); + }), + ); + + it.effect("an unrelated active thread does not protect this workspace", () => + Effect.gen(function* () { + threadRefs.push( + thread({ + id: ThreadId.make("elsewhere"), + worktreePath: projectRoot, + settledOverride: "active", + }), + ); + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"], ["shutdown"]]); + }), + ); + + it.effect.each([ + "queued", + "preparing", + "starting", + "running", + "waiting", + "request", + "background", + ])("protects an archived or settled peer with %s work", (scenario) => + Effect.gen(function* () { + const peer = thread({ + id: ThreadId.make("peer"), + archivedAt: DateTime.makeUnsafe("2026-10-14T00:00:00Z"), + }); + threadRefs.push(peer); + if (scenario === "request") pendingRequests[peer.id] = true; + else if (scenario === "background") + peer.pendingBackgroundTasks = [{ kind: "monitor", taskId: "watch" }]; + else runStatuses[peer.id] = [scenario, "completed"]; + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([]); + }), + ); + + it.effect("stops a deleted thread's runtime without requiring a visible shell", () => + Effect.gen(function* () { + threadRefs[0]!.deletedAt = DateTime.makeUnsafe("2026-10-14T00:00:00Z"); + threadRefs[0]!.settledOverride = null; + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"], ["shutdown"]]); + }), + ); + + it.effect.each([ + "completed", + "running", + "missing-owner", + "active-owner", + "mcp-child", + "deleted-owner", + "parent-owner", + ])("handles a %s native child", (scenario) => + Effect.gen(function* () { + const child = thread({ + id: ThreadId.make("child"), + settledOverride: null, + creationSource: scenario === "mcp-child" ? "mcp" : "provider", + lineage: { + rootThreadId: scenario === "missing-owner" ? ThreadId.make("missing") : target.threadId, + parentThreadId: target.threadId, + relationshipToParent: "subagent", + }, + }); + if (scenario === "parent-owner") + child.lineage = { ...child.lineage, rootThreadId: child.id }; + threadRefs.push(child); + if (scenario === "running") runStatuses[child.id] = ["running"]; + if (scenario === "active-owner") threadRefs[0]!.settledOverride = "active"; + if (scenario === "deleted-owner") { + threadRefs[0]!.deletedAt = DateTime.makeUnsafe("2026-10-14T00:00:00Z"); + threadRefs[0]!.settledOverride = null; + } + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength( + scenario === "completed" || scenario === "deleted-owner" || scenario === "parent-owner" + ? 1 + : 0, + ); + }), + ); + + it.effect("leftover commands do not hold a settled workspace open", () => + Effect.gen(function* () { + threadRefs[0]!.pendingBackgroundTasks = [{ kind: "command", taskId: "dev-tui" }]; + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"], ["shutdown"]]); + }), + ); + + it.effect("a revival during the status probe prevents shutdown", () => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + statusObserved = () => { + threadRefs[0]!.settledOverride = "active"; + }; + yield* stop(); + expect(commands).toEqual([["status", "--json"]]); + }), + ); + + it.effect.each(["active-peer", "queued-child", "moved-target"])( + "rechecks %s added during the status probe", + (scenario) => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + statusObserved = () => { + if (scenario === "moved-target") threadRefs[0]!.worktreePath = projectRoot; + else { + const peer = thread({ + id: ThreadId.make("new-peer"), + settledOverride: scenario === "active-peer" ? "active" : "settled", + }); + threadRefs.push(peer); + if (scenario === "queued-child") runStatuses[peer.id] = ["queued", "completed"]; + } + }; + yield* stop(); + expect(commands).toEqual([["status", "--json"]]); + }), + ); + + it.effect.each(["peer", "native-owner"])( + "protects a %s manually revived after the final snapshot", + (scenario) => + Effect.gen(function* () { + const peerId = ThreadId.make("peer"); + threadRefs.push(thread({ id: peerId })); + if (scenario === "native-owner") + threadRefs.splice( + 1, + 0, + thread({ + id: ThreadId.make("native-child"), + settledOverride: null, + creationSource: "provider", + lineage: { + rootThreadId: peerId, + parentThreadId: peerId, + relationshipToParent: "subagent", + }, + }), + ); + yield* Effect.promise(() => socket()); + statusObserved = () => { + recordsObserved = (id) => { + if (snapshotReads < 2 || id !== peerId) return; + threadRefs = threadRefs.map((thread) => + thread.id === peerId ? { ...thread, settledOverride: "active" } : thread, + ); + }; + }; + yield* stop(); + expect(commands).toEqual([["status", "--json"]]); + }), + ); + + it.effect.each(["wrong-root", "custom-data-dir", "symlink-socket", "symlink-data-dir"])( + "rejects %s without shutdown", + (scenario) => + Effect.gen(function* () { + if (scenario === "wrong-root") state.root = projectRoot; + if (scenario === "custom-data-dir") state.dataDir = projectRoot; + if (scenario === "symlink-socket") { + const alternate = NodePath.join(home, "e2e.sock"); + yield* Effect.promise(() => socket(alternate)); + yield* Effect.promise(() => + NodeFSP.symlink(alternate, NodePath.join(root, ".dev-tui", "ipc.sock")), + ); + } else if (scenario === "symlink-data-dir") { + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(root, ".dev-tui"), { recursive: true }), + ); + yield* Effect.promise(() => + NodeFSP.symlink(projectRoot, NodePath.join(root, ".dev-tui")), + ); + state.dataDir = projectRoot; + yield* Effect.promise(() => socket()); + } else yield* Effect.promise(() => socket()); + expect(yield* stop().pipe(Effect.flip)).toMatchObject({ reason: "status" }); + expect(commands).toEqual([["status", "--json"]]); + }), + ); + + it.effect("a failed shutdown remains retryable", () => + Effect.gen(function* () { + yield* Effect.promise(() => socket()); + shutdownCode = 1; + expect(yield* stop().pipe(Effect.flip)).toMatchObject({ reason: "status" }); + shutdownCode = 0; + yield* stop(); + expect(commands.filter((args) => args[0] === "shutdown")).toHaveLength(2); + }), + ); + + it.effect("does not send another shutdown to a stopping runtime", () => + Effect.gen(function* () { + state.stopping = true; + yield* Effect.promise(() => socket()); + yield* stop(); + expect(commands).toEqual([["status", "--json"]]); + }), + ); + }); +}); diff --git a/apps/server/src/workspace/DevServer.ts b/apps/server/src/workspace/DevServer.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff07697044e6ea6ccc31ae3294cf9e9b7fb153cd --- /dev/null +++ b/apps/server/src/workspace/DevServer.ts @@ -0,0 +1,415 @@ +// @effect-diagnostics nodeBuiltinImport:off - Adapts dev-tui's Unix socket protocol. +import { + DevServerError, + isProviderNativeSubagentThread, + type OrchestrationV2AppThread, + type DevServerStatus, + type DevServerTarget, + type ThreadId, +} from "@t3tools/contracts"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient } from "effect/http"; +import * as NodeNet from "node:net"; + +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { withWorkspaceLease } from "./workspaceLease.ts"; + +const RuntimeStatus = Schema.Struct({ + root: Schema.String, + dataDir: Schema.String, + environment: Schema.String, + stopping: Schema.Boolean, + services: Schema.Record( + Schema.String, + Schema.Struct({ + status: Schema.String, + url: Schema.NullOr(Schema.String), + }), + ), +}); +const decodeStatus = Schema.decodeUnknownSync(Schema.fromJsonString(RuntimeStatus)); +const decodeFrame = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + out: Schema.optional(Schema.String), + code: Schema.optional(Schema.Int), + }), + ), +); + +function isParked( + thread: Pick< + OrchestrationV2AppThread, + "archivedAt" | "deletedAt" | "settledOverride" | "settledAt" + >, +): boolean { + return ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.settledOverride === "settled" || + (thread.settledOverride !== "active" && thread.settledAt !== null) + ); +} + +const liveRunStatuses = new Set(["queued", "preparing", "starting", "running", "waiting"]); + +export class DevServer extends Context.Service< + DevServer, + { + readonly status: (target: DevServerTarget) => Effect.Effect; + readonly start: (target: DevServerTarget) => Effect.Effect; + readonly stopUnused: (threadId: ThreadId) => Effect.Effect; + } +>()("t3/workspace/DevServer") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const http = HttpClient.withScope(yield* HttpClient.HttpClient); + const path = yield* Path.Path; + const projects = yield* ProjectStore.ProjectStoreV2; + const threads = yield* ProjectionStore.ProjectionStoreV2; + const processRunner = yield* ProcessRunner.ProcessRunner; + const home = yield* HostProcess.HomeDirectory; + const environment = yield* HostProcess.Environment; + const devTuiDirectory = path.join(home, "dev", "dev-tui"); + + const workspace = Effect.fn("DevServer.workspace")(function* (target: DevServerTarget) { + const thread = + target._tag === "Thread" + ? yield* threads + .getThread(target.threadId) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause }))) + : null; + const projectId = target._tag === "Project" ? target.projectId : thread?.projectId; + if (!projectId) return yield* new DevServerError({ reason: "workspace" }); + const project = yield* projects + .get(projectId) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause }))); + if (Option.isNone(project)) return yield* new DevServerError({ reason: "workspace" }); + const selectedRoot = + target._tag === "Project" + ? target.workspacePath + : (thread?.worktreePath ?? project.value.workspaceRoot); + return yield* fs + .realPath(selectedRoot) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause }))); + }); + + const canStart = Effect.fn("DevServer.canStart")(function* (root: string) { + return yield* Effect.all([ + fs.exists(path.join(root, "dev.sh")), + fs.exists(path.join(root, "dev.config.ts")), + fs.exists(path.join(devTuiDirectory, "dev.sh")), + ]).pipe( + Effect.map((present) => present.every(Boolean)), + Effect.orElseSucceed(() => false), + ); + }); + + const request = (root: string, command: ReadonlyArray) => + Effect.callback((resume) => { + const socket = NodeNet.connect(path.join(root, ".dev-tui", "ipc.sock")); + let frameBuffer = ""; + let output = ""; + let settled = false; + const finish = (result: Effect.Effect) => { + if (settled) return; + settled = true; + socket.destroy(); + resume(result); + }; + socket.setEncoding("utf8"); + socket.once("connect", () => socket.write(`${JSON.stringify(command)}\n`)); + socket.once("error", (cause: NodeJS.ErrnoException) => { + finish( + cause.code === "ENOENT" || cause.code === "ECONNREFUSED" + ? Effect.succeed(null) + : Effect.fail(new DevServerError({ reason: "status", cause })), + ); + }); + socket.on("data", (chunk: string) => { + if (socket.bytesRead > 128 * 1024) { + finish(Effect.fail(new DevServerError({ reason: "status" }))); + return; + } + frameBuffer += chunk; + const lines = frameBuffer.split("\n"); + frameBuffer = lines.pop() ?? ""; + try { + for (const line of lines) { + if (!line) continue; + const frame = decodeFrame(line); + output += frame.out ?? ""; + if (frame.code !== undefined) { + finish( + frame.code === 0 + ? Effect.succeed(output) + : Effect.fail(new DevServerError({ reason: "status" })), + ); + return; + } + } + } catch (cause) { + finish(Effect.fail(new DevServerError({ reason: "status", cause }))); + } + }); + socket.once("close", () => finish(Effect.fail(new DevServerError({ reason: "status" })))); + return Effect.sync(() => { + socket.destroy(); + }); + }).pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => Effect.fail(new DevServerError({ reason: "status" })), + }), + ); + + const probe = Effect.fn("DevServer.probe")(function* (root: string) { + const output = yield* request(root, ["status", "--json"]); + if (output === null) return null; + const runtime = yield* Effect.try(() => decodeStatus(output)).pipe( + Effect.mapError((cause) => new DevServerError({ reason: "status", cause })), + ); + const [runtimeRoot, dataDir, expectedDataDir] = yield* Effect.all([ + fs.realPath(runtime.root), + fs.realPath(runtime.dataDir), + fs.realPath(path.join(root, ".dev-tui")), + ]).pipe(Effect.mapError((cause) => new DevServerError({ reason: "status", cause }))); + if (runtimeRoot !== root || dataDir !== expectedDataDir) { + return yield* new DevServerError({ reason: "status" }); + } + return runtime; + }); + + const read = Effect.fn("DevServer.read")(function* (root: string) { + const [runtime, launchable] = yield* Effect.all([probe(root), canStart(root)]); + const services = Object.entries(runtime?.services ?? {}).map(([id, service]) => ({ + id, + ...service, + })); + const client = runtime?.services.client; + let frontendUrl: string | null = null; + const clientUrl = client?.url; + if (client?.status === "ready" && clientUrl && !runtime?.stopping) { + const url = yield* Effect.try(() => new URL(clientUrl)).pipe( + Effect.mapError((cause) => new DevServerError({ reason: "status", cause })), + ); + if ( + url.protocol !== "http:" || + !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || + url.username || + url.password + ) { + return yield* new DevServerError({ reason: "status" }); + } + const reachable = yield* http.get(url.href).pipe( + Effect.map((response) => response.status >= 200 && response.status < 400), + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.timeout("3 seconds"), + Effect.scoped, + Effect.orElseSucceed(() => false), + ); + if (reachable) frontendUrl = url.href; + } + return { + workspaceRoot: root, + canStart: launchable, + environment: runtime?.environment ?? null, + stopping: runtime?.stopping ?? false, + services, + frontendUrl, + } satisfies DevServerStatus; + }); + + const canonicalPath = (directory: string) => + fs + .realPath(directory) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail(new DevServerError({ reason: "workspace", cause })), + ), + ); + + const unused = Effect.fn("DevServer.unused")(function* (threadId: ThreadId, root: string) { + const [snapshot, projectShells, targetRecords] = yield* Effect.all([ + threads.getShellSnapshot(), + projects.listShells(), + threads.getThreadRecords(threadId, ["runs", "runtimeRequests"]), + ]).pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause }))); + const target = targetRecords.thread; + if ( + !isParked(target) || + targetRecords.runs.some((run) => liveRunStatuses.has(run.status)) || + targetRecords.runtimeRequests.some((request) => request.status === "pending") + ) + return false; + const candidates = [...snapshot.threads, ...snapshot.archivedThreads]; + const owners = new Map(candidates.map((thread) => [thread.id, thread])); + const projectRoots = new Map( + projectShells.map((project) => [project.id, project.workspaceRoot]), + ); + const targetDirectory = target.worktreePath ?? projectRoots.get(target.projectId); + if (targetDirectory === undefined || (yield* canonicalPath(targetDirectory)) !== root) + return false; + const canonicalRoots = new Map(); + for (const thread of candidates) { + const directory = thread.worktreePath ?? projectRoots.get(thread.projectId); + if (directory === undefined) return false; + if (!canonicalRoots.has(directory)) + canonicalRoots.set(directory, yield* canonicalPath(directory)); + const resolved = canonicalRoots.get(directory); + if (resolved == null) continue; + const relative = path.relative(root, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + continue; + if ( + thread.activeRunId !== null || + thread.activityRunStatus != null || + liveRunStatuses.has(thread.status) || + thread.pendingRuntimeRequest !== null || + backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) + ) + return false; + const records = + thread.id === threadId + ? targetRecords + : yield* threads + .getThreadRecords(thread.id, ["runs", "runtimeRequests"]) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause }))); + const current = records.thread; + const currentDirectory = current.worktreePath ?? projectRoots.get(current.projectId); + if (currentDirectory === undefined) return false; + const currentRoot = yield* canonicalPath(currentDirectory); + if (currentRoot === null) continue; + const currentRelative = path.relative(root, currentRoot); + if ( + currentRelative === ".." || + currentRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(currentRelative) + ) + continue; + const ownerId = + current.lineage.rootThreadId === current.id + ? current.lineage.parentThreadId + : current.lineage.rootThreadId; + const nativeChild = isProviderNativeSubagentThread(current); + const owner = + nativeChild && ownerId !== null && (ownerId === threadId || owners.has(ownerId)) + ? yield* threads.getThreadRecords(ownerId, []).pipe( + Effect.map((records) => records.thread), + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })), + ) + : null; + if (!(nativeChild ? owner !== null && isParked(owner) : isParked(current))) return false; + if ( + records.runs.some((run) => liveRunStatuses.has(run.status)) || + records.runtimeRequests.some((request) => request.status === "pending") + ) + return false; + } + return true; + }); + + const stopUnused = Effect.fn("DevServer.stopUnused")(function* (threadId: ThreadId) { + const records = yield* threads.getThreadRecords(threadId, []).pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause })), + ); + if (records === null) return; + const thread = records.thread; + const project = yield* projects + .getShell(thread.projectId) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "workspace", cause }))); + if (Option.isNone(project)) return; + const root = yield* canonicalPath(thread.worktreePath ?? project.value.workspaceRoot); + if (root === null) return; + yield* withWorkspaceLease( + root, + Effect.gen(function* () { + const socketPath = path.join(root, ".dev-tui", "ipc.sock"); + const socketInfo = yield* fs + .stat(socketPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail(new DevServerError({ reason: "status", cause })), + ), + ); + if (socketInfo === null || !(yield* unused(threadId, root))) return; + const runtime = yield* probe(root); + if (runtime === null || runtime.stopping) return; + if (!(yield* unused(threadId, root))) return; + const canonicalSocket = yield* fs + .realPath(socketPath) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "status", cause }))); + if (canonicalSocket !== socketPath) return yield* new DevServerError({ reason: "status" }); + const currentSocket = yield* fs + .stat(socketPath) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "status", cause }))); + if ( + currentSocket.dev !== socketInfo.dev || + !Option.isSome(socketInfo.ino) || + !Option.isSome(currentSocket.ino) || + currentSocket.ino.value !== socketInfo.ino.value + ) { + return yield* new DevServerError({ reason: "status" }); + } + const accepted = yield* request(root, ["shutdown"]); + if (accepted !== null) + yield* Effect.logDebug("dev server shutdown requested", { + threadId, + workspaceRoot: root, + }); + }), + ); + }); + + return DevServer.of({ + stopUnused, + status: (target) => workspace(target).pipe(Effect.flatMap(read)), + start: (target) => + workspace(target).pipe( + Effect.flatMap((root) => + withWorkspaceLease( + root, + Effect.gen(function* () { + const current = yield* read(root); + if (current.frontendUrl) return current; + if (!current.canStart) return yield* new DevServerError({ reason: "unsupported" }); + const launched = yield* processRunner + .run({ + command: "bash", + args: [path.join(root, "dev.sh"), "--headless"], + cwd: root, + env: { ...environment, DEV_TUI_DIR: devTuiDirectory }, + timeout: "180 seconds", + maxOutputBytes: 128 * 1024, + outputMode: "truncate", + }) + .pipe(Effect.mapError((cause) => new DevServerError({ reason: "launch", cause }))); + if (launched.code !== 0) return yield* new DevServerError({ reason: "launch" }); + const ready = yield* read(root); + if (!ready.frontendUrl) return yield* new DevServerError({ reason: "not-ready" }); + return ready; + }), + ), + ), + ), + }); +}); + +export const layer = Layer.effect(DevServer, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4f3b642c8c476129eb3daa98654289b38f1ffdc0..3bd45e6c8916ccbf10db91d720ee9a2b021e45c7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -190,6 +190,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; +import * as DevServer from "./workspace/DevServer.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -1234,6 +1235,7 @@ const layerWsRpc = ( const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const devServer = yield* DevServer.DevServer; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -1675,6 +1677,7 @@ const layerWsRpc = ( otlpLogsEnabled: config.otlpLogsUrl !== undefined, }, settings, + devServerStatus: true, ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, @@ -2601,6 +2604,8 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.devServerStatus]: (input) => devServer.status(input), + [WS_METHODS.devServerStart]: (input) => devServer.start(input), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( Effect.mapError( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f9f4ec5c7961b7ca5217f3914522af0ccb6836a3..79ea3e2f69453ad2cde2c9d2323f1bdb3474ae1e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1157,6 +1157,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; import { readEnvironmentScope } from "../../state/session"; +import { ComposerDevServerStatus } from "./ComposerDevServerStatus"; import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -7851,6 +7852,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } className="flex shrink-0 flex-nowrap items-center justify-end gap-2" > + {routeKind === "server" ? ( + + ) : activeProjectId && gitCwd ? ( + + ) : null} {showComposerAttachAction ? ( <> ({ + runtime: undefined as "server" | undefined, + connection: { + httpBaseUrl: "http://127.0.0.1:3773", + target: { _tag: "PrimaryConnectionTarget" }, + }, + phase: "connected" as "available" | "connecting" | "connected" | "reconnecting", + supported: true, + canRead: true, + canStart: true, + status: vi.fn(), + start: vi.fn(), + open: vi.fn(), + reveal: vi.fn(), + toast: vi.fn(), +})); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => + atom === "config" + ? { devServerStatus: state.supported } + : atom === "read" + ? state.canRead + : state.canStart, +})); +vi.mock("~/state/devServer", () => ({ + devServerEnvironment: { + status: { id: "status", permissionAtom: () => "read" }, + start: { id: "start", permissionAtom: () => "start-permission" }, + }, +})); +vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => "config" } })); +vi.mock("~/state/preview", () => ({ previewEnvironment: { open: { id: "open" } } })); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: ({ id }: { id: "status" | "start" | "open" }) => state[id], +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: () => ({ connection: { phase: state.phase } }), +})); +vi.mock("~/state/session", () => ({ + useEnvironmentScope: () => true, + readPreparedConnection: () => state.connection, +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/browser/previewRuntime", () => ({ previewRuntimeFor: () => state.runtime })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openBrowser: state.reveal }) }, +})); +vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); +vi.mock("../ui/toast", () => ({ + stackedThreadToast: (input: unknown) => input, + toastManager: { add: state.toast }, +})); +vi.mock("../ui/button", () => ({ + Button: ({ children, ...props }: React.ComponentProps<"button">) => ( + + ), +})); + +import { ComposerDevServerStatus } from "./ComposerDevServerStatus"; + +const threadRef = { + environmentId: EnvironmentId.make("host"), + threadId: ThreadId.make("thread-a"), +}; +const target = { _tag: "Thread", threadId: threadRef.threadId } as const; +const ready: DevServerStatus = { + workspaceRoot: "/workspace/a", + canStart: true, + environment: "local", + stopping: false, + services: [{ id: "client", status: "ready", url: "http://localhost:3001" }], + frontendUrl: "http://localhost:3001/", +}; +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +}; +let renderer: ReactTestRenderer | undefined; +const mount = async () => { + await act(async () => { + renderer = create(); + }); +}; +const click = () => renderer!.root.findByType("button").props.onClick(); + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { visibilityState: "visible" }); + vi.useFakeTimers(); + state.runtime = undefined; + state.connection = { + httpBaseUrl: "http://127.0.0.1:3773", + target: { _tag: "PrimaryConnectionTarget" }, + }; + state.phase = "connected"; + state.supported = true; + state.canRead = true; + state.canStart = true; + for (const mock of [state.status, state.start, state.open, state.reveal, state.toast]) + mock.mockReset(); + state.status.mockResolvedValue(AsyncResult.success(ready)); + state.start.mockResolvedValue(AsyncResult.success(ready)); + state.open.mockResolvedValue(AsyncResult.success({ tabId: "tab" })); +}); +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("composer dev server lifecycle", () => { + it("does not query old hosts or connections without destination read grants", async () => { + state.supported = false; + await mount(); + expect(state.status).not.toHaveBeenCalled(); + state.supported = true; + state.canRead = false; + await act(async () => + renderer!.update(), + ); + expect(state.status).not.toHaveBeenCalled(); + }); + + it("waits for connection readiness with cached capability and grants, then resumes on reconnect", async () => { + state.phase = "available"; + await mount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(state.status).not.toHaveBeenCalled(); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + + state.phase = "connecting"; + await act(async () => + renderer!.update(), + ); + expect(state.status).not.toHaveBeenCalled(); + + state.phase = "connected"; + await act(async () => + renderer!.update(), + ); + expect(state.status).toHaveBeenCalledTimes(1); + expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Servers 1/1"); + + state.phase = "reconnecting"; + await act(async () => + renderer!.update(), + ); + expect(renderer!.root.findByType("button").props.disabled).toBe(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + click(); + }); + expect(state.status).toHaveBeenCalledTimes(1); + expect(state.open).not.toHaveBeenCalled(); + + state.phase = "connected"; + await act(async () => + renderer!.update(), + ); + expect(state.status).toHaveBeenCalledTimes(2); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(state.status).toHaveBeenCalledTimes(3); + }); + + it("starts the selected draft workspace once across repeated clicks", async () => { + const stopped = { ...ready, services: [], frontendUrl: null }; + state.status.mockResolvedValue(AsyncResult.success(stopped)); + const launch = deferred>>(); + state.start.mockReturnValue(launch.promise); + const draft = { + _tag: "Project", + projectId: ProjectId.make("project"), + workspacePath: "/workspace/selected", + } as const; + await act(async () => { + renderer = create(); + }); + await act(async () => { + click(); + click(); + }); + expect(state.start).toHaveBeenCalledTimes(1); + expect(state.start.mock.calls[0]?.[0]).toEqual({ + environmentId: threadRef.environmentId, + input: draft, + }); + expect(state.open).not.toHaveBeenCalled(); + await act(async () => launch.resolve(AsyncResult.success(ready))); + expect(state.open).toHaveBeenCalledTimes(1); + expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab"); + }); + + it("clears status immediately and discards an old launch when switching thread", async () => { + state.status.mockResolvedValue(AsyncResult.success({ ...ready, frontendUrl: null })); + const launch = deferred>>(); + state.start.mockReturnValue(launch.promise); + await mount(); + await act(async () => click()); + const nextRead = deferred>>(); + state.status.mockReturnValue(nextRead.promise); + const next = { ...threadRef, threadId: ThreadId.make("thread-b") }; + await act(async () => + renderer!.update( + , + ), + ); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + await act(async () => launch.resolve(AsyncResult.success(ready))); + expect(state.open).not.toHaveBeenCalled(); + expect(state.reveal).not.toHaveBeenCalled(); + await act(async () => + nextRead.resolve(AsyncResult.success({ ...ready, services: [], canStart: false })), + ); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + }); + + it("clears pending after a launch failure and allows a new attempt", async () => { + state.status.mockResolvedValue(AsyncResult.success({ ...ready, frontendUrl: null })); + state.start.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Server did not start"))), + ); + await mount(); + await act(async () => click()); + expect(state.toast).toHaveBeenCalledTimes(1); + expect(state.open).not.toHaveBeenCalled(); + expect(renderer!.root.findByType("button").props.disabled).toBe(false); + await act(async () => click()); + expect(state.start).toHaveBeenCalledTimes(2); + expect(state.open).toHaveBeenCalledTimes(1); + expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab"); + }); + + it("maps a remote desktop frontend to the destination environment host", async () => { + state.connection = { + httpBaseUrl: "http://100.65.180.100:3773", + target: { _tag: "RemoteConnectionTarget" }, + }; + await mount(); + await act(async () => click()); + expect(state.open).toHaveBeenCalledWith( + expect.objectContaining({ threadRef, url: "http://100.65.180.100:3001/" }), + ); + state.runtime = "server"; + await act(async () => click()); + expect(state.open).toHaveBeenLastCalledWith( + expect.objectContaining({ threadRef, url: ready.frontendUrl }), + ); + }); + + it("resumes bounded polling after a successful explicit retry and pauses on another error", async () => { + state.status.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("unavailable")))); + await mount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(state.status).toHaveBeenCalledTimes(1); + state.phase = "reconnecting"; + await act(async () => + renderer!.update(), + ); + state.phase = "connected"; + await act(async () => + renderer!.update(), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(state.status).toHaveBeenCalledTimes(1); + state.status.mockResolvedValue(AsyncResult.success(ready)); + await act(async () => click()); + expect(state.open).toHaveBeenCalledTimes(1); + expect(state.reveal).toHaveBeenCalledWith(threadRef, "tab"); + state.status.mockResolvedValue( + AsyncResult.success({ ...ready, services: [], frontendUrl: null }), + ); + const reads = state.status.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(state.status).toHaveBeenCalledTimes(reads + 1); + expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Start frontend"); + state.status.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("unavailable")))); + await act(async () => { + await vi.advanceTimersByTimeAsync(15000); + }); + expect(state.status).toHaveBeenCalledTimes(reads + 2); + expect(renderer!.root.findByType("button").props["aria-label"]).toBe("Servers unavailable"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerDevServerStatus.tsx b/apps/web/src/components/chat/ComposerDevServerStatus.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0219466d94b167eb106514d82e89722347a6b199 --- /dev/null +++ b/apps/web/src/components/chat/ComposerDevServerStatus.tsx @@ -0,0 +1,156 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + AuthPreviewOperateScope, + type DevServerStatus, + type DevServerTarget, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { GlobeIcon } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; + +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { previewRuntimeFor } from "~/browser/previewRuntime"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { devServerEnvironment } from "~/state/devServer"; +import { useEnvironment } from "~/state/environments"; +import { previewEnvironment } from "~/state/preview"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useEnvironmentScope } from "~/state/session"; +import { openPreviewSession } from "../preview/openPreviewSession"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +interface Props { + threadRef: ScopedThreadRef; + target: DevServerTarget; +} + +export function ComposerDevServerStatus(props: Props) { + const config = useAtomValue(serverEnvironment.configValueAtom(props.threadRef.environmentId)); + const canRead = useAtomValue( + devServerEnvironment.status.permissionAtom(props.threadRef.environmentId), + ); + if (!config?.devServerStatus || !canRead) return null; + return ; +} + +function DevServerButton({ threadRef, target }: Props) { + const connected = useEnvironment(threadRef.environmentId)?.connection.phase === "connected"; + const [status, setStatus] = useState(null); + const [pending, setPending] = useState(false); + const [unavailable, setUnavailable] = useState(false); + const live = useRef(true); + const busy = useRef(false); + const load = useAtomCommand(devServerEnvironment.status, { reportFailure: false }); + const start = useAtomCommand(devServerEnvironment.start, { reportFailure: false }); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const canPreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); + const canStart = useAtomValue(devServerEnvironment.start.permissionAtom(threadRef.environmentId)); + const poll = useEffectEvent(async () => { + if (busy.current || document.visibilityState === "hidden") return true; + const result = await load({ environmentId: threadRef.environmentId, input: target }); + if (!live.current || busy.current) return true; + if (result._tag === "Failure") { + setStatus(null); + setUnavailable(true); + return false; + } + setStatus(result.value); + setUnavailable(false); + return true; + }); + + useEffect(() => { + live.current = true; + let active = true; + let timer: ReturnType | undefined; + const refresh = async () => { + if (!active) return; + if ((await poll()) && active) timer = setTimeout(() => void refresh(), 5000); + }; + if (connected && !unavailable) void refresh(); + return () => { + active = false; + live.current = false; + clearTimeout(timer); + }; + }, [connected, unavailable]); + + const open = async () => { + if (!connected || busy.current) return; + busy.current = true; + setPending(true); + try { + let result = await load({ environmentId: threadRef.environmentId, input: target }); + if (!live.current) return; + if (result._tag === "Failure") throw result.cause; + if (!result.value.frontendUrl && result.value.canStart) { + result = await start({ environmentId: threadRef.environmentId, input: target }); + if (!live.current) return; + if (result._tag === "Failure") throw result.cause; + } + setStatus(result.value); + setUnavailable(false); + const frontendUrl = result.value.frontendUrl; + if (!frontendUrl) + throw new Error("Frontend unavailable. Check the dev server and try again."); + const url = + previewRuntimeFor(threadRef.environmentId) === "server" + ? frontendUrl + : resolveDiscoveredServerUrl(threadRef.environmentId, frontendUrl); + const opened = await openPreviewSession({ threadRef, url, openPreview }); + if (!live.current) return; + if (opened._tag === "Failure") throw opened.cause; + useRightPanelStore.getState().openBrowser(threadRef, opened.value.tabId); + } catch (cause) { + if (!live.current) return; + const error = Cause.isCause(cause) ? Cause.squash(cause) : cause; + setUnavailable(true); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open frontend", + description: + error instanceof Error ? error.message : "Check the dev server and try again.", + }), + ); + } finally { + busy.current = false; + if (live.current) setPending(false); + } + }; + + if (!status && !unavailable) return null; + if (status && !status.canStart && status.services.length === 0) return null; + const ready = status?.services.filter((service) => service.status === "ready").length ?? 0; + const total = status?.services.length ?? 0; + const label = pending + ? "Opening…" + : unavailable + ? "Servers unavailable" + : status?.stopping + ? "Stopping…" + : total > 0 + ? `Servers ${ready}/${total}` + : "Start frontend"; + const canOpen = status?.frontendUrl || (status?.canStart && canStart) || unavailable; + const detail = status?.services.map((service) => `${service.id}: ${service.status}`).join(", "); + + return ( + + ); +} diff --git a/apps/web/src/state/devServer.ts b/apps/web/src/state/devServer.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfa0e87410ab0c520255bc22eac07e4c118ff065 --- /dev/null +++ b/apps/web/src/state/devServer.ts @@ -0,0 +1,14 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const devServerEnvironment = { + status: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:dev-server:status", + tag: WS_METHODS.devServerStatus, + }), + start: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:dev-server:start", + tag: WS_METHODS.devServerStart, + }), +}; diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 7e95c18b6a9d3dc04623d09708dbc5216a4f68a5..cb074149c744fe4c9b098b550c33a33e110afdab 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,9 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthFilesystemReadScope, + AuthOrchestrationReadScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, @@ -83,6 +86,39 @@ describe("command permissions", () => { ), ); + it.effect("requires destination read grants for dev status and terminal authority to start", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const status = createCommandPermissions(runtime, WS_METHODS.devServerStatus); + const start = createCommandPermissions(runtime, WS_METHODS.devServerStart); + const readGrant = { + ...grant(false), + scopes: [AuthFilesystemReadScope, AuthOrchestrationReadScope], + permissions: [AuthFilesystemReadScope, AuthOrchestrationReadScope], + }; + registry.set(sessions(env), AsyncResult.success(readGrant)); + expect(registry.get(status.permissionAtom(env))).toBe(true); + expect(registry.get(start.permissionAtom(env))).toBe(false); + expect((yield* start.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...readGrant, + scopes: [...readGrant.scopes, AuthTerminalOperateScope], + permissions: [...readGrant.permissions, AuthTerminalOperateScope], + }), + ); + yield* start.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(status.permissionAtom(other))).toBe(false); + expect(registry.get(start.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("requires the destination settings grant to run storage cleanup", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d1834503960c150503e57859a43451b01fc5b8e9..9b9727c4475a5db1c74db328b7ac886b6f7abaf1 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,9 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthTerminalOperateScope, + AuthFilesystemReadScope, + AuthOrchestrationReadScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, @@ -12,6 +15,8 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, + [WS_METHODS.devServerStatus]: AuthFilesystemReadScope, + [WS_METHODS.devServerStart]: AuthTerminalOperateScope, [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, // Endpoint checks exist to configure exports, so they need the same grant as saving them. [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, @@ -57,6 +62,8 @@ export function clientRpcRequiredScopes( method: string, input: unknown, ): readonly AuthEnvironmentScope[] { + if (method === WS_METHODS.devServerStatus) + return [AuthFilesystemReadScope, AuthOrchestrationReadScope]; if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) { const payload = decodePrepareThread(input); if (payload.mode === "worktree" && payload.threadId !== undefined) diff --git a/packages/contracts/src/devServer.ts b/packages/contracts/src/devServer.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5b2401b2aebed98261175e6b4311f85dd185675 --- /dev/null +++ b/packages/contracts/src/devServer.ts @@ -0,0 +1,44 @@ +import * as Schema from "effect/Schema"; +import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const DevServerTarget = Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("Thread"), threadId: ThreadId }), + Schema.Struct({ + _tag: Schema.Literal("Project"), + projectId: ProjectId, + workspacePath: TrimmedNonEmptyString, + }), +]); +export type DevServerTarget = typeof DevServerTarget.Type; + +export const DevServerStatus = Schema.Struct({ + workspaceRoot: Schema.String, + canStart: Schema.Boolean, + environment: Schema.NullOr(Schema.String), + stopping: Schema.Boolean, + services: Schema.Array( + Schema.Struct({ id: Schema.String, status: Schema.String, url: Schema.NullOr(Schema.String) }), + ), + frontendUrl: Schema.NullOr(Schema.String), +}); +export type DevServerStatus = typeof DevServerStatus.Type; + +export class DevServerError extends Schema.TaggedError()("DevServerError", { + reason: Schema.Literals(["workspace", "status", "unsupported", "launch", "not-ready"]), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + switch (this.reason) { + case "workspace": + return "Workspace unavailable. Open another thread and try again."; + case "status": + return "Server status unavailable. Check the dev server and try again."; + case "unsupported": + return "Start this workspace's dev server in a terminal, then try again."; + case "launch": + return "Server did not start. Check .dev-tui/server.log and try again."; + case "not-ready": + return "Frontend unavailable. Check the dev server and try again."; + } + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4e9bb7d5298709d7fba552eb62f0f060dbf81d3f..f01963184112b54af75e4c839da9bc8c1ef62928 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -65,6 +65,7 @@ export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; export * from "./clientRpcPermissions.ts"; +export * from "./devServer.ts"; export * from "./mergebot.ts"; export * from "./scriptProgress.ts"; export * from "./desktopTerminal.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8e9ed9df40eb9ed7ce19f264c37c55aa9a914325..2ba683054dd7dff4bd2f3440ff877417136b231a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,4 +1,5 @@ import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; +import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts"; import { UiSettingsProfile, UiSettingsPatchInput, @@ -397,6 +398,9 @@ export const WS_METHODS = { projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", + devServerStatus: "devServer.status", + devServerStart: "devServer.start", + // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -1277,6 +1281,17 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); +const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, { + payload: DevServerTarget, + success: DevServerStatus, + error: Schema.Union([DevServerError, EnvironmentAuthorizationError]), +}); +const WsDevServerStartRpc = Rpc.make(WS_METHODS.devServerStart, { + payload: DevServerTarget, + success: DevServerStatus, + error: Schema.Union([DevServerError, EnvironmentAuthorizationError]), +}); + const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, @@ -2064,6 +2079,8 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsListEntriesRpc, WsProjectsReadFileRpc, WsProjectsWatchRpc, + WsDevServerStatusRpc, + WsDevServerStartRpc, WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6162b494ce28798d2a1d493c61990449252c887f..9ef598ea970bbb0cc6b978cd049ee9fa33d36286 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -674,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; export const ServerConfig = Schema.Struct({ + devServerStatus: Schema.optionalKey(Schema.Boolean), mergebot: Schema.optionalKey(MergebotConfiguration), environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, -- 2.54.0