| 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 | 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 | 305 | describe("RPC scope middleware", () => { |
| 242 | 306 | const tested = [WS_METHODS.serverProbe, WS_METHODS.serverRetryResourceTelemetry] as const; |
| 243 | 307 | const group = WsRpcGroup.omit( |
apps/server/src/observability/RpcInstrumentation.ts+2| ... | ... | @@ -141,6 +141,8 @@ const RPC_AGGREGATES = { |
| 141 | 141 | [WS_METHODS.projectCloneRetry]: "source-control", |
| 142 | 142 | [WS_METHODS.subscribeProjectClones]: "source-control", |
| 143 | 143 | [WS_METHODS.projectsListEntries]: "workspace", |
| 144 | [WS_METHODS.devServerStatus]: "workspace", | |
| 145 | [WS_METHODS.devServerStart]: "workspace", | |
| 144 | 146 | [WS_METHODS.projectsWatch]: "workspace", |
| 145 | 147 | [WS_METHODS.projectsReadFile]: "workspace", |
| 146 | 148 | [WS_METHODS.projectsSearchContents]: "workspace", |
apps/server/src/server.ts+8| ... | ... | @@ -118,6 +118,7 @@ import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| 118 | 118 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 119 | 119 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 120 | 120 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; |
| 121 | import * as DevServer from "./workspace/DevServer.ts"; | |
| 121 | 122 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 122 | 123 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| 123 | 124 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | ... | @@ -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 | 752 | Layer.provideMerge(layerWorkspace), |
| 745 | 753 | Layer.provideMerge(ProjectEnrichmentService.layer), |
| 746 | 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 | 190 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 191 | 191 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; |
| 192 | 192 | import * as MergebotQueue from "./mergebot/MergebotQueue.ts"; |
| 193 | import * as DevServer from "./workspace/DevServer.ts"; | |
| 193 | 194 | import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; |
| 194 | 195 | import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; |
| 195 | 196 | import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; |
| ... | ... | @@ -1234,6 +1235,7 @@ const layerWsRpc = ( |
| 1234 | 1235 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 1235 | 1236 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; |
| 1236 | 1237 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| 1238 | const devServer = yield* DevServer.DevServer; | |
| 1237 | 1239 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; |
| 1238 | 1240 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 1239 | 1241 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| ... | ... | @@ -1675,6 +1677,7 @@ const layerWsRpc = ( |
| 1675 | 1677 | otlpLogsEnabled: config.otlpLogsUrl !== undefined, |
| 1676 | 1678 | }, |
| 1677 | 1679 | settings, |
| 1680 | devServerStatus: true, | |
| 1678 | 1681 | ...(mergebotQueue.configuration ? { mergebot: mergebotQueue.configuration } : {}), |
| 1679 | 1682 | shellResumeCompletionMarker: true, |
| 1680 | 1683 | threadResumeCompletionMarker: true, |
| ... | ... | @@ -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 | 2609 | [WS_METHODS.projectsReadFile]: (input) => |
| 2605 | 2610 | workspaceFileSystem.readFile(input).pipe( |
| 2606 | 2611 | Effect.mapError( |
apps/web/src/components/chat/ChatComposer.tsx+17| ... | ... | @@ -1157,6 +1157,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 1157 | 1157 | import { usePanelAnimationSettings } from "../../panelAnimations"; |
| 1158 | 1158 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 1159 | 1159 | import { readEnvironmentScope } from "../../state/session"; |
| 1160 | import { ComposerDevServerStatus } from "./ComposerDevServerStatus"; | |
| 1160 | 1161 | import { serverEnvironment } from "../../state/server"; |
| 1161 | 1162 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 1162 | 1163 | |
| ... | ... | @@ -7851,6 +7852,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7851 | 7852 | } |
| 7852 | 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 | 7871 | {showComposerAttachAction ? ( |
| 7855 | 7872 | <> |
| 7856 | 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 | 8 | import { vi } from "vite-plus/test"; |
| 9 | 9 | import { |
| 10 | 10 | AuthOrchestrationOperateScope, |
| 11 | AuthTerminalOperateScope, | |
| 12 | AuthFilesystemReadScope, | |
| 13 | AuthOrchestrationReadScope, | |
| 11 | 14 | AuthSettingsWriteScope, |
| 12 | 15 | AuthSourceControlWriteScope, |
| 13 | 16 | ThreadId, |
| ... | ... | @@ -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 | 122 | it.effect("requires the destination settings grant to run storage cleanup", () => |
| 87 | 123 | Effect.scoped( |
| 88 | 124 | Effect.gen(function* () { |
packages/contracts/src/clientRpcPermissions.ts+7| ... | ... | @@ -2,6 +2,9 @@ import * as Schema from "effect/Schema"; |
| 2 | 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; |
| 3 | 3 | import { |
| 4 | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthTerminalOperateScope, | |
| 6 | AuthFilesystemReadScope, | |
| 7 | AuthOrchestrationReadScope, | |
| 5 | 8 | AuthSettingsWriteScope, |
| 6 | 9 | AuthSourceControlWriteScope, |
| 7 | 10 | type AuthEnvironmentScope, |
| ... | ... | @@ -12,6 +15,8 @@ import { WS_METHODS } from "./rpc.ts"; |
| 12 | 15 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 13 | 16 | export const CLIENT_GUARDED_RPC_SCOPES = { |
| 14 | 17 | [WS_METHODS.uiSettingsPatch]: AuthSettingsWriteScope, |
| 18 | [WS_METHODS.devServerStatus]: AuthFilesystemReadScope, | |
| 19 | [WS_METHODS.devServerStart]: AuthTerminalOperateScope, | |
| 15 | 20 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, |
| 16 | 21 | // Endpoint checks exist to configure exports, so they need the same grant as saving them. |
| 17 | 22 | [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, |
| ... | ... | @@ -57,6 +62,8 @@ export function clientRpcRequiredScopes( |
| 57 | 62 | method: string, |
| 58 | 63 | input: unknown, |
| 59 | 64 | ): readonly AuthEnvironmentScope[] { |
| 65 | if (method === WS_METHODS.devServerStatus) | |
| 66 | return [AuthFilesystemReadScope, AuthOrchestrationReadScope]; | |
| 60 | 67 | if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) { |
| 61 | 68 | const payload = decodePrepareThread(input); |
| 62 | 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 | 65 | export * from "./worktreeSetup.ts"; |
| 66 | 66 | export * from "./secretRequest.ts"; |
| 67 | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | export * from "./devServer.ts"; | |
| 68 | 69 | export * from "./mergebot.ts"; |
| 69 | 70 | export * from "./scriptProgress.ts"; |
| 70 | 71 | export * from "./desktopTerminal.ts"; |
packages/contracts/src/rpc.ts+17| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { MergebotQueueInput, MergebotRepoQueue } from "./mergebot.ts"; |
| 2 | import { DevServerTarget, DevServerStatus, DevServerError } from "./devServer.ts"; | |
| 2 | 3 | import { |
| 3 | 4 | UiSettingsProfile, |
| 4 | 5 | UiSettingsPatchInput, |
| ... | ... | @@ -397,6 +398,9 @@ export const WS_METHODS = { |
| 397 | 398 | projectsEnsureScratch: "projects.ensureScratch", |
| 398 | 399 | projectsCreateNew: "projects.createNew", |
| 399 | 400 | |
| 401 | devServerStatus: "devServer.status", | |
| 402 | devServerStart: "devServer.start", | |
| 403 | ||
| 400 | 404 | // Shell methods |
| 401 | 405 | shellOpenInEditor: "shell.openInEditor", |
| 402 | 406 | |
| ... | ... | @@ -1277,6 +1281,17 @@ const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { |
| 1277 | 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 | 1295 | const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { |
| 1281 | 1296 | payload: ProjectReadFileInput, |
| 1282 | 1297 | success: ProjectReadFileResult, |
| ... | ... | @@ -2064,6 +2079,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 2064 | 2079 | WsProjectsListEntriesRpc, |
| 2065 | 2080 | WsProjectsReadFileRpc, |
| 2066 | 2081 | WsProjectsWatchRpc, |
| 2082 | WsDevServerStatusRpc, | |
| 2083 | WsDevServerStartRpc, | |
| 2067 | 2084 | WsProjectsSearchContentsRpc, |
| 2068 | 2085 | WsProjectsSearchEntriesRpc, |
| 2069 | 2086 | WsProjectsEnsureScratchRpc, |
packages/contracts/src/server.ts+1| ... | ... | @@ -674,6 +674,7 @@ export const ServerDirectEndpoint = Schema.Struct({ |
| 674 | 674 | export type ServerDirectEndpoint = typeof ServerDirectEndpoint.Type; |
| 675 | 675 | |
| 676 | 676 | export const ServerConfig = Schema.Struct({ |
| 677 | devServerStatus: Schema.optionalKey(Schema.Boolean), | |
| 677 | 678 | mergebot: Schema.optionalKey(MergebotConfiguration), |
| 678 | 679 | environment: ExecutionEnvironmentDescriptor, |
| 679 | 680 | auth: ServerAuthDescriptor, |