| author | |
| committer | |
| log | b3a01d86bc6c4c62f7469c43b02aa53b97fe3f44 |
| tree | c2b013eb3ba8f1216cfe18386b06be72ade2bf06 |
| parent | 3ab8402d3db4645647a187fd218bbe385cc6a123 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol16 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 | ... | @@ -238,6 +238,70 @@ it("requires operate permission for tool updates even alongside a read-only chec |
| 238 | expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); | 238 | expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); |
| 239 | }); | 239 | }); |
| 240 | 240 | ||
| 241 | describe("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 | |||
| 241 | describe("RPC scope middleware", () => { | 305 | describe("RPC scope middleware", () => { |
| 242 | const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const; | 306 | const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const; |
| 243 | const group = WsRpcGroup.omit( | 307 | const group = WsRpcGroup.omit( |
apps/server/src/observability/RpcInstrumentation.ts+2| ... | @@ -141,6 +141,8 @@ const RPC_AGGREGATES = { | ... | @@ -141,6 +141,8 @@ const RPC_AGGREGATES = { |
| 141 | [WS_METHODS.projectCloneRetry]: "source-control", | 141 | [WS_METHODS.projectCloneRetry]: "source-control", |
| 142 | [WS_METHODS.subscribeProjectClones]: "source-control", | 142 | [WS_METHODS.subscribeProjectClones]: "source-control", |
| 143 | [WS_METHODS.projectsListEntries]: "workspace", | 143 | [WS_METHODS.projectsListEntries]: "workspace", |
| 144 | [WS_METHODS.devServerStatus]: "workspace", | ||
| 145 | [WS_METHODS.devServerStart]: "workspace", | ||
| 144 | [WS_METHODS.projectsWatch]: "workspace", | 146 | [WS_METHODS.projectsWatch]: "workspace", |
| 145 | [WS_METHODS.projectsReadFile]: "workspace", | 147 | [WS_METHODS.projectsReadFile]: "workspace", |
| 146 | [WS_METHODS.projectsSearchContents]: "workspace", | 148 | [WS_METHODS.projectsSearchContents]: "workspace", |
apps/server/src/server.ts+8| ... | @@ -118,6 +118,7 @@ import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | ... | @@ -118,6 +118,7 @@ import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| 118 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; | 118 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; |
| 121 | import * as DevServer from "./workspace/DevServer.ts"; | ||
| 121 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 122 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 122 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 123 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 123 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 124 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | @@ -741,6 +742,13 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase | ... | @@ -741,6 +742,13 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase |
| 741 | ), | 742 | ), |
| 742 | ), | 743 | ), |
| 743 | ), | 744 | ), |
| 745 | Layer.provideMerge( | ||
| 746 | DevServer.layer.pipe( | ||
| 747 | Layer.provide(ProjectionStoreV2.layer), | ||
| 748 | Layer.provide(ProjectStore.layer), | ||
| 749 | Layer.provide(ProcessRunner.layer), | ||
| 750 | ), | ||
| 751 | ), | ||
| 744 | Layer.provideMerge(layerWorkspace), | 752 | Layer.provideMerge(layerWorkspace), |
| 745 | Layer.provideMerge(ProjectEnrichmentService.layer), | 753 | Layer.provideMerge(ProjectEnrichmentService.layer), |
| 746 | Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), | 754 | 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. | ||
| 2 | import { afterEach, beforeEach, describe, expect, vi } from "vite-plus/test"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { | ||
| 6 | ProjectId, | ||
| 7 | ThreadId, | ||
| 8 | DevServerError, | ||
| 9 | type OrchestrationV2ThreadShell, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 18 | import * as NodeFSP from "node:fs/promises"; | ||
| 19 | import * as NodeNet from "node:net"; | ||
| 20 | import * as NodeOS from "node:os"; | ||
| 21 | import * as NodePath from "node:path"; | ||
| 22 | |||
| 23 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 24 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 25 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 26 | import * as DevServer from "./DevServer.ts"; | ||
| 27 | |||
| 28 | const target = { _tag: "Thread", threadId: ThreadId.make("thread") } as const; | ||
| 29 | let home: string; | ||
| 30 | let root: string; | ||
| 31 | let projectRoot: string; | ||
| 32 | let server: NodeNet.Server | undefined; | ||
| 33 | let state: { | ||
| 34 | root: string; | ||
| 35 | dataDir: string; | ||
| 36 | environment: string; | ||
| 37 | stopping: boolean; | ||
| 38 | services: Record<string, { status: string; url: string | null }>; | ||
| 39 | }; | ||
| 40 | let malformed = false; | ||
| 41 | let httpStatus = 200; | ||
| 42 | const launches = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>(); | ||
| 43 | const requestedUrls: string[] = []; | ||
| 44 | const commands: ReadonlyArray<string>[] = []; | ||
| 45 | let statusObserved: (() => void) | undefined; | ||
| 46 | let shutdownCode = 0; | ||
| 47 | let snapshotReads = 0; | ||
| 48 | let recordsObserved: ((threadId: ThreadId) => void) | undefined; | ||
| 49 | type 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 | >; | ||
| 66 | type Thread = { -readonly [K in keyof ThreadFields]: ThreadFields[K] }; | ||
| 67 | let threadRefs: Thread[]; | ||
| 68 | let projectRefs: { id: ProjectId; workspaceRoot: string }[]; | ||
| 69 | let runStatuses: Record<string, string[]>; | ||
| 70 | let pendingRequests: Record<string, boolean>; | ||
| 71 | function 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 | |||
| 91 | beforeEach(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 | |||
| 119 | afterEach(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 | |||
| 125 | async 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 | |||
| 157 | async 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 | |||
| 166 | const 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 | ); | ||
| 220 | const run = <A>(effect: Effect.Effect<A, DevServerError, DevServer.DevServer>) => | ||
| 221 | effect.pipe(Effect.provide(layer())); | ||
| 222 | const 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 | |||
| 233 | describe("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. | ||
| 2 | import { | ||
| 3 | DevServerError, | ||
| 4 | isProviderNativeSubagentThread, | ||
| 5 | type OrchestrationV2AppThread, | ||
| 6 | type DevServerStatus, | ||
| 7 | type DevServerTarget, | ||
| 8 | type ThreadId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | ||
| 11 | import * as HostProcess from "@t3tools/shared/HostProcess"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as FileSystem from "effect/FileSystem"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Path from "effect/Path"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import { FetchHttpClient, HttpClient } from "effect/http"; | ||
| 20 | import * as NodeNet from "node:net"; | ||
| 21 | |||
| 22 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 23 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | ||
| 24 | import * as ProcessRunner from "../processRunner.ts"; | ||
| 25 | import { withWorkspaceLease } from "./workspaceLease.ts"; | ||
| 26 | |||
| 27 | const 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 | }); | ||
| 40 | const decodeStatus = Schema.decodeUnknownSync(Schema.fromJsonString(RuntimeStatus)); | ||
| 41 | const 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 | |||
| 50 | function 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 | |||
| 64 | const liveRunStatuses = new Set(["queued", "preparing", "starting", "running", "waiting"]); | ||
| 65 | |||
| 66 | export 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 | |||
| 75 | const 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 | |||
| 415 | export const layer = Layer.effect(DevServer, make); | ||
apps/server/src/ws.ts+5| ... | @@ -190,6 +190,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta | ... | @@ -190,6 +190,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta |
| 190 | import * as PortScanner from "./preview/PortScanner.ts"; | 190 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 192 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; | 192 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; |
| 193 | import * as DevServer from "./workspace/DevServer.ts"; | ||
| 193 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; | 194 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 194 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; | 195 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; |
| 195 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; | 196 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| ... | @@ -1234,6 +1235,7 @@ const layerWsRpc = ( | ... | @@ -1234,6 +1235,7 @@ const layerWsRpc = ( |
| 1234 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 1235 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 1235 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; | 1236 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; |
| 1236 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 1237 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 1238 | const devServer = yield* DevServer.DevServer; | ||
| 1237 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | 1239 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; |
| 1238 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | 1240 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 1239 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; | 1241 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| ... | @@ -1675,6 +1677,7 @@ const layerWsRpc = ( | ... | @@ -1675,6 +1677,7 @@ const layerWsRpc = ( |
| 1675 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, | 1677 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, |
| 1676 | }, | 1678 | }, |
| 1677 | settings, | 1679 | settings, |
| 1680 | devServerStatus: true, | ||
| 1678 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), | 1681 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), |
| 1679 | shellResumeCompletionMarker: true, | 1682 | shellResumeCompletionMarker: true, |
| 1680 | threadResumeCompletionMarker: true, | 1683 | threadResumeCompletionMarker: true, |
| ... | @@ -2601,6 +2604,8 @@ const layerWsRpc = ( | ... | @@ -2601,6 +2604,8 @@ const layerWsRpc = ( |
| 2601 | }), | 2604 | }), |
| 2602 | ), | 2605 | ), |
| 2603 | ), | 2606 | ), |
| 2607 | [WS_METHODS.devServerStatus]: (input) => devServer.status(input), | ||
| 2608 | [WS_METHODS.devServerStart]: (input) => devServer.start(input), | ||
| 2604 | [WS_METHODS.projectsReadFile]: (input) => | 2609 | [WS_METHODS.projectsReadFile]: (input) => |
| 2605 | workspaceFileSystem.readFile(input).pipe( | 2610 | workspaceFileSystem.readFile(input).pipe( |
| 2606 | Effect.mapError( | 2611 | Effect.mapError( |
apps/web/src/components/chat/ChatComposer.tsx+17| ... | @@ -1157,6 +1157,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; | ... | @@ -1157,6 +1157,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 1157 | import { usePanelAnimationSettings } from "../../panelAnimations"; | 1157 | import { usePanelAnimationSettings } from "../../panelAnimations"; |
| 1158 | import { useAtomCommand } from "../../state/use-atom-command"; | 1158 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 1159 | import { readEnvironmentScope } from "../../state/session"; | 1159 | import { readEnvironmentScope } from "../../state/session"; |
| 1160 | import { ComposerDevServerStatus } from "./ComposerDevServerStatus"; | ||
| 1160 | import { serverEnvironment } from "../../state/server"; | 1161 | import { serverEnvironment } from "../../state/server"; |
| 1161 | import type { ReviewCommentContext } from "../../reviewCommentContext"; | 1162 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 1162 | 1163 | ||
| ... | @@ -7851,6 +7852,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -7851,6 +7852,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7851 | } | 7852 | } |
| 7852 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" | 7853 | className="flex shrink-0 flex-nowrap items-center justify-end gap-2" |
| 7853 | > | 7854 | > |
| 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} | ||
| 7854 | {showComposerAttachAction ? ( | 7871 | {showComposerAttachAction ? ( |
| 7855 | <> | 7872 | <> |
| 7856 | <input | 7873 | <input |
apps/web/src/components/chat/ComposerDevServerStatus.test.tsx created+307| ... | @@ -0,0 +1,307 @@ | ||
| 1 | import { EnvironmentId, ProjectId, ThreadId, type DevServerStatus } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import { AsyncResult } from "effect/reactivity"; | ||
| 4 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | const 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 | })); | ||
| 23 | vi.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 | })); | ||
| 31 | vi.mock("~/state/devServer", () => ({ | ||
| 32 | devServerEnvironment: { | ||
| 33 | status: { id: "status", permissionAtom: () => "read" }, | ||
| 34 | start: { id: "start", permissionAtom: () => "start-permission" }, | ||
| 35 | }, | ||
| 36 | })); | ||
| 37 | vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => "config" } })); | ||
| 38 | vi.mock("~/state/preview", () => ({ previewEnvironment: { open: { id: "open" } } })); | ||
| 39 | vi.mock("~/state/use-atom-command", () => ({ | ||
| 40 | useAtomCommand: ({ id }: { id: "status" | "start" | "open" }) => state[id], | ||
| 41 | })); | ||
| 42 | vi.mock("~/state/environments", () => ({ | ||
| 43 | useEnvironment: () => ({ connection: { phase: state.phase } }), | ||
| 44 | })); | ||
| 45 | vi.mock("~/state/session", () => ({ | ||
| 46 | useEnvironmentScope: () => true, | ||
| 47 | readPreparedConnection: () => state.connection, | ||
| 48 | })); | ||
| 49 | vi.mock("~/env", () => ({ isElectron: true })); | ||
| 50 | vi.mock("~/browser/previewRuntime", () => ({ previewRuntimeFor: () => state.runtime })); | ||
| 51 | vi.mock("~/rightPanelStore", () => ({ | ||
| 52 | useRightPanelStore: { getState: () => ({ openBrowser: state.reveal }) }, | ||
| 53 | })); | ||
| 54 | vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); | ||
| 55 | vi.mock("../ui/toast", () => ({ | ||
| 56 | stackedThreadToast: (input: unknown) => input, | ||
| 57 | toastManager: { add: state.toast }, | ||
| 58 | })); | ||
| 59 | vi.mock("../ui/button", () => ({ | ||
| 60 | Button: ({ children, ...props }: React.ComponentProps<"button">) => ( | ||
| 61 | <button {...props}>{children}</button> | ||
| 62 | ), | ||
| 63 | })); | ||
| 64 | |||
| 65 | import { ComposerDevServerStatus } from "./ComposerDevServerStatus"; | ||
| 66 | |||
| 67 | const threadRef = { | ||
| 68 | environmentId: EnvironmentId.make("host"), | ||
| 69 | threadId: ThreadId.make("thread-a"), | ||
| 70 | }; | ||
| 71 | const target = { _tag: "Thread", threadId: threadRef.threadId } as const; | ||
| 72 | const 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 | }; | ||
| 80 | const deferred = <T,>() => { | ||
| 81 | let resolve!: (value: T) => void; | ||
| 82 | const promise = new Promise<T>((complete) => { | ||
| 83 | resolve = complete; | ||
| 84 | }); | ||
| 85 | return { promise, resolve }; | ||
| 86 | }; | ||
| 87 | let renderer: ReactTestRenderer | undefined; | ||
| 88 | const mount = async () => { | ||
| 89 | await act(async () => { | ||
| 90 | renderer = create(<ComposerDevServerStatus threadRef={threadRef} target={target} />); | ||
| 91 | }); | ||
| 92 | }; | ||
| 93 | const click = () => renderer!.root.findByType("button").props.onClick(); | ||
| 94 | |||
| 95 | beforeEach(() => { | ||
| 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 | }); | ||
| 114 | afterEach(async () => { | ||
| 115 | await act(async () => renderer?.unmount()); | ||
| 116 | renderer = undefined; | ||
| 117 | vi.useRealTimers(); | ||
| 118 | vi.unstubAllGlobals(); | ||
| 119 | }); | ||
| 120 | |||
| 121 | describe("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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | AuthPreviewOperateScope, | ||
| 4 | type DevServerStatus, | ||
| 5 | type DevServerTarget, | ||
| 6 | type ScopedThreadRef, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import { GlobeIcon } from "lucide-react"; | ||
| 10 | import { useEffect, useEffectEvent, useRef, useState } from "react"; | ||
| 11 | |||
| 12 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | ||
| 13 | import { previewRuntimeFor } from "~/browser/previewRuntime"; | ||
| 14 | import { useRightPanelStore } from "~/rightPanelStore"; | ||
| 15 | import { devServerEnvironment } from "~/state/devServer"; | ||
| 16 | import { useEnvironment } from "~/state/environments"; | ||
| 17 | import { previewEnvironment } from "~/state/preview"; | ||
| 18 | import { serverEnvironment } from "~/state/server"; | ||
| 19 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 20 | import { useEnvironmentScope } from "~/state/session"; | ||
| 21 | import { openPreviewSession } from "../preview/openPreviewSession"; | ||
| 22 | import { Button } from "../ui/button"; | ||
| 23 | import { stackedThreadToast, toastManager } from "../ui/toast"; | ||
| 24 | |||
| 25 | interface Props { | ||
| 26 | threadRef: ScopedThreadRef; | ||
| 27 | target: DevServerTarget; | ||
| 28 | } | ||
| 29 | |||
| 30 | export 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 | |||
| 39 | function 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 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export 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"; | ... | @@ -8,6 +8,9 @@ import { describe, expect, it } from "@effect/vitest"; |
| 8 | import { vi } from "vite-plus/test"; | 8 | import { vi } from "vite-plus/test"; |
| 9 | import { | 9 | import { |
| 10 | AuthOrchestrationOperateScope, | 10 | AuthOrchestrationOperateScope, |
| 11 | AuthTerminalOperateScope, | ||
| 12 | AuthFilesystemReadScope, | ||
| 13 | AuthOrchestrationReadScope, | ||
| 11 | AuthSettingsWriteScope, | 14 | AuthSettingsWriteScope, |
| 12 | AuthSourceControlWriteScope, | 15 | AuthSourceControlWriteScope, |
| 13 | ThreadId, | 16 | ThreadId, |
| ... | @@ -83,6 +86,39 @@ describe("command permissions", () => { | ... | @@ -83,6 +86,39 @@ describe("command permissions", () => { |
| 83 | ), | 86 | ), |
| 84 | ); | 87 | ); |
| 85 | 88 | ||
| 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 | |||
| 86 | it.effect("requires the destination settings grant to run storage cleanup", () => | 122 | it.effect("requires the destination settings grant to run storage cleanup", () => |
| 87 | Effect.scoped( | 123 | Effect.scoped( |
| 88 | Effect.gen(function* () { | 124 | Effect.gen(function* () { |
packages/contracts/src/clientRpcPermissions.ts+7| ... | @@ -2,6 +2,9 @@ import * as Schema from "effect/Schema"; | ... | @@ -2,6 +2,9 @@ import * as Schema from "effect/Schema"; |
| 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; | 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; |
| 3 | import { | 3 | import { |
| 4 | AuthOrchestrationOperateScope, | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthTerminalOperateScope, | ||
| 6 | AuthFilesystemReadScope, | ||
| 7 | AuthOrchestrationReadScope, | ||
| 5 | AuthSettingsWriteScope, | 8 | AuthSettingsWriteScope, |
| 6 | AuthSourceControlWriteScope, | 9 | AuthSourceControlWriteScope, |
| 7 | type AuthEnvironmentScope, | 10 | type AuthEnvironmentScope, |
| ... | @@ -12,6 +15,8 @@ import { WS_METHODS } from "./rpc.ts"; | ... | @@ -12,6 +15,8 @@ import { WS_METHODS } from "./rpc.ts"; |
| 12 | /** Incremental client enforcement; the server still authorizes every request. */ | 15 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 13 | export const CLIENT_GUARDED_RPC_SCOPES = { | 16 | export const CLIENT_GUARDED_RPC_SCOPES = { |
| 14 | [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, | 17 | [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, |
| 18 | [WS_METHODS.devServerStatus]: AuthFilesystemReadScope, | ||
| 19 | [WS_METHODS.devServerStart]: AuthTerminalOperateScope, | ||
| 15 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, | 20 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, |
| 16 | // Endpoint checks exist to configure exports, so they need the same grant as saving them. | 21 | // Endpoint checks exist to configure exports, so they need the same grant as saving them. |
| 17 | [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, | 22 | [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, |
| ... | @@ -57,6 +62,8 @@ export function clientRpcRequiredScopes( | ... | @@ -57,6 +62,8 @@ export function clientRpcRequiredScopes( |
| 57 | method: string, | 62 | method: string, |
| 58 | input: unknown, | 63 | input: unknown, |
| 59 | ): readonly AuthEnvironmentScope[] { | 64 | ): readonly AuthEnvironmentScope[] { |
| 65 | if (method === WS_METHODS.devServerStatus) | ||
| 66 | return [AuthFilesystemReadScope, AuthOrchestrationReadScope]; | ||
| 60 | if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) { | 67 | if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) { |
| 61 | const payload = decodePrepareThread(input); | 68 | const payload = decodePrepareThread(input); |
| 62 | if (payload.mode === "worktree" && payload.threadId !== undefined) | 69 | if (payload.mode === "worktree" && payload.threadId !== undefined) |
packages/contracts/src/devServer.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 3 | |||
| 4 | export 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 | ]); | ||
| 12 | export type DevServerTarget = typeof DevServerTarget.Type; | ||
| 13 | |||
| 14 | export 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 | }); | ||
| 24 | export type DevServerStatus = typeof DevServerStatus.Type; | ||
| 25 | |||
| 26 | export 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"; | ... | @@ -65,6 +65,7 @@ export * from "./rpc.ts"; |
| 65 | export * from "./worktreeSetup.ts"; | 65 | export * from "./worktreeSetup.ts"; |
| 66 | export * from "./secretRequest.ts"; | 66 | export * from "./secretRequest.ts"; |
| 67 | export * from "./clientRpcPermissions.ts"; | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | export * from "./devServer.ts"; | ||
| 68 | export * from "./mergebot.ts"; | 69 | export * from "./mergebot.ts"; |
| 69 | export * from "./scriptProgress.ts"; | 70 | export * from "./scriptProgress.ts"; |
| 70 | export * from "./desktopTerminal.ts"; | 71 | export * from "./desktopTerminal.ts"; |
packages/contracts/src/rpc.ts+17| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; | 1 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; |
| 2 | import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts"; | ||
| 2 | import { | 3 | import { |
| 3 | UiSettingsProfile, | 4 | UiSettingsProfile, |
| 4 | UiSettingsPatchInput, | 5 | UiSettingsPatchInput, |
| ... | @@ -397,6 +398,9 @@ export const WS_METHODS = { | ... | @@ -397,6 +398,9 @@ export const WS_METHODS = { |
| 397 | projectsEnsureScratch: "projects.ensureScratch", | 398 | projectsEnsureScratch: "projects.ensureScratch", |
| 398 | projectsCreateNew: "projects.createNew", | 399 | projectsCreateNew: "projects.createNew", |
| 399 | 400 | ||
| 401 | devServerStatus: "devServer.status", | ||
| 402 | devServerStart: "devServer.start", | ||
| 403 | |||
| 400 | // Shell methods | 404 | // Shell methods |
| 401 | shellOpenInEditor: "shell.openInEditor", | 405 | shellOpenInEditor: "shell.openInEditor", |
| 402 | 406 | ||
| ... | @@ -1277,6 +1281,17 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { | ... | @@ -1277,6 +1281,17 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { |
| 1277 | error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), | 1281 | error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), |
| 1278 | }); | 1282 | }); |
| 1279 | 1283 | ||
| 1284 | const WsDevServerStatusRpc = Rpc.make(WS_METHODS.devServerStatus, { | ||
| 1285 | payload: DevServerTarget, | ||
| 1286 | success: DevServerStatus, | ||
| 1287 | error: Schema.Union([DevServerError, EnvironmentAuthorizationError]), | ||
| 1288 | }); | ||
| 1289 | const WsDevServerStartRpc = Rpc.make(WS_METHODS.devServerStart, { | ||
| 1290 | payload: DevServerTarget, | ||
| 1291 | success: DevServerStatus, | ||
| 1292 | error: Schema.Union([DevServerError, EnvironmentAuthorizationError]), | ||
| 1293 | }); | ||
| 1294 | |||
| 1280 | const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { | 1295 | const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { |
| 1281 | payload: ProjectReadFileInput, | 1296 | payload: ProjectReadFileInput, |
| 1282 | success: ProjectReadFileResult, | 1297 | success: ProjectReadFileResult, |
| ... | @@ -2064,6 +2079,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -2064,6 +2079,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 2064 | WsProjectsListEntriesRpc, | 2079 | WsProjectsListEntriesRpc, |
| 2065 | WsProjectsReadFileRpc, | 2080 | WsProjectsReadFileRpc, |
| 2066 | WsProjectsWatchRpc, | 2081 | WsProjectsWatchRpc, |
| 2082 | WsDevServerStatusRpc, | ||
| 2083 | WsDevServerStartRpc, | ||
| 2067 | WsProjectsSearchContentsRpc, | 2084 | WsProjectsSearchContentsRpc, |
| 2068 | WsProjectsSearchEntriesRpc, | 2085 | WsProjectsSearchEntriesRpc, |
| 2069 | WsProjectsEnsureScratchRpc, | 2086 | WsProjectsEnsureScratchRpc, |
packages/contracts/src/server.ts+1| ... | @@ -674,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ | ... | @@ -674,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ |
| 674 | export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; | 674 | export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; |
| 675 | 675 | ||
| 676 | export const ServerConfig = Schema.Struct({ | 676 | export const ServerConfig = Schema.Struct({ |
| 677 | devServerStatus: Schema.optionalKey(Schema.Boolean), | ||
| 677 | mergebot: Schema.optionalKey(MergebotConfiguration), | 678 | mergebot: Schema.optionalKey(MergebotConfiguration), |
| 678 | environment: ExecutionEnvironmentDescriptor, | 679 | environment: ExecutionEnvironmentDescriptor, |
| 679 | auth: ServerAuthDescriptor, | 680 | auth: ServerAuthDescriptor, |