| author | |
| committer | |
| log | 5fd632c357914553f2baf746df0454527db89b09 |
| tree | 641e95313c6817fe19e61c6553c09ef3c0d71fa5 |
| parent | bb2164c9459eeeff603aebd540701918e0d9286f |
- Create or reuse cwd project, create initial thread, and include bootstrap IDs in `server.welcome`
- Update web event routing to expand the project and auto-navigate from `/` to the bootstrapped thread
- Extend WebSocket welcome contract and add server/web tests for bootstrap payload handling6 files changed, 166 insertions(+), 17 deletions(-)
apps/server/src/wsServer.test.ts+41-2| ... | ... | @@ -448,23 +448,62 @@ describe("WebSocket Server", () => { |
| 448 | 448 | |
| 449 | 449 | const ws = await connectWs(port); |
| 450 | 450 | connections.push(ws); |
| 451 | await waitForMessage(ws); // welcome | |
| 451 | const welcome = (await waitForMessage(ws)) as WsPush; // welcome | |
| 452 | expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome); | |
| 453 | expect(welcome.data).toEqual( | |
| 454 | expect.objectContaining({ | |
| 455 | cwd: "/test/bootstrap-workspace", | |
| 456 | projectName: "bootstrap-workspace", | |
| 457 | bootstrapProjectId: expect.any(String), | |
| 458 | bootstrapThreadId: expect.any(String), | |
| 459 | }), | |
| 460 | ); | |
| 452 | 461 | |
| 453 | 462 | const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); |
| 454 | 463 | expect(snapshotResponse.error).toBeUndefined(); |
| 455 | 464 | const snapshot = snapshotResponse.result as { |
| 456 | projects: Array<{ workspaceRoot: string; title: string; defaultModel: string | null }>; | |
| 465 | projects: Array<{ | |
| 466 | id: string; | |
| 467 | workspaceRoot: string; | |
| 468 | title: string; | |
| 469 | defaultModel: string | null; | |
| 470 | }>; | |
| 471 | threads: Array<{ | |
| 472 | id: string; | |
| 473 | projectId: string; | |
| 474 | title: string; | |
| 475 | model: string; | |
| 476 | branch: string | null; | |
| 477 | worktreePath: string | null; | |
| 478 | }>; | |
| 457 | 479 | }; |
| 480 | const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; | |
| 481 | const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; | |
| 482 | expect(bootstrapProjectId).toBeDefined(); | |
| 483 | expect(bootstrapThreadId).toBeDefined(); | |
| 458 | 484 | |
| 459 | 485 | expect(snapshot.projects).toEqual( |
| 460 | 486 | expect.arrayContaining([ |
| 461 | 487 | expect.objectContaining({ |
| 488 | id: bootstrapProjectId, | |
| 462 | 489 | workspaceRoot: "/test/bootstrap-workspace", |
| 463 | 490 | title: "bootstrap-workspace", |
| 464 | 491 | defaultModel: "gpt-5-codex", |
| 465 | 492 | }), |
| 466 | 493 | ]), |
| 467 | 494 | ); |
| 495 | expect(snapshot.threads).toEqual( | |
| 496 | expect.arrayContaining([ | |
| 497 | expect.objectContaining({ | |
| 498 | id: bootstrapThreadId, | |
| 499 | projectId: bootstrapProjectId, | |
| 500 | title: "New thread", | |
| 501 | model: "gpt-5-codex", | |
| 502 | branch: null, | |
| 503 | worktreePath: null, | |
| 504 | }), | |
| 505 | ]), | |
| 506 | ); | |
| 468 | 507 | }); |
| 469 | 508 | |
| 470 | 509 | it("logs outbound websocket push events in dev mode", async () => { |
apps/server/src/wsServer.ts+47-7| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | ORCHESTRATION_WS_CHANNELS, |
| 17 | 17 | ORCHESTRATION_WS_METHODS, |
| 18 | 18 | ProjectId, |
| 19 | ThreadId, | |
| 19 | 20 | TerminalEvent, |
| 20 | 21 | WS_CHANNELS, |
| 21 | 22 | WS_METHODS, |
| ... | ... | @@ -337,22 +338,56 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 337 | 338 | |
| 338 | 339 | yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); |
| 339 | 340 | |
| 341 | let welcomeBootstrapProjectId: ProjectId | undefined; | |
| 342 | let welcomeBootstrapThreadId: ThreadId | undefined; | |
| 343 | ||
| 340 | 344 | if (autoBootstrapProjectFromCwd) { |
| 341 | 345 | yield* Effect.gen(function* () { |
| 342 | 346 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); |
| 343 | const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd); | |
| 344 | if (!existing) { | |
| 347 | const existingProject = snapshot.projects.find( | |
| 348 | (project) => project.workspaceRoot === cwd && project.deletedAt === null, | |
| 349 | ); | |
| 350 | let bootstrapProjectId: ProjectId; | |
| 351 | let bootstrapProjectDefaultModel: string; | |
| 352 | ||
| 353 | if (!existingProject) { | |
| 345 | 354 | const createdAt = new Date().toISOString(); |
| 346 | const projectName = path.basename(cwd) || "project"; | |
| 355 | bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); | |
| 356 | const bootstrapProjectTitle = path.basename(cwd) || "project"; | |
| 357 | bootstrapProjectDefaultModel = "gpt-5-codex"; | |
| 347 | 358 | yield* orchestrationEngine.dispatch({ |
| 348 | 359 | type: "project.create", |
| 349 | 360 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), |
| 350 | projectId: ProjectId.makeUnsafe(crypto.randomUUID()), | |
| 351 | title: projectName, | |
| 361 | projectId: bootstrapProjectId, | |
| 362 | title: bootstrapProjectTitle, | |
| 352 | 363 | workspaceRoot: cwd, |
| 353 | defaultModel: "gpt-5-codex", | |
| 364 | defaultModel: bootstrapProjectDefaultModel, | |
| 365 | createdAt, | |
| 366 | }); | |
| 367 | } else { | |
| 368 | bootstrapProjectId = existingProject.id; | |
| 369 | bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; | |
| 370 | } | |
| 371 | ||
| 372 | const existingThread = snapshot.threads.find( | |
| 373 | (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null, | |
| 374 | ); | |
| 375 | if (!existingThread) { | |
| 376 | const createdAt = new Date().toISOString(); | |
| 377 | const threadId = ThreadId.makeUnsafe(crypto.randomUUID()); | |
| 378 | yield* orchestrationEngine.dispatch({ | |
| 379 | type: "thread.create", | |
| 380 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | |
| 381 | threadId, | |
| 382 | projectId: bootstrapProjectId, | |
| 383 | title: "New thread", | |
| 384 | model: bootstrapProjectDefaultModel, | |
| 385 | branch: null, | |
| 386 | worktreePath: null, | |
| 354 | 387 | createdAt, |
| 355 | 388 | }); |
| 389 | welcomeBootstrapProjectId = bootstrapProjectId; | |
| 390 | welcomeBootstrapThreadId = threadId; | |
| 356 | 391 | } |
| 357 | 392 | }).pipe( |
| 358 | 393 | Effect.mapError( |
| ... | ... | @@ -581,7 +616,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 581 | 616 | const welcome: WsPush = { |
| 582 | 617 | type: "push", |
| 583 | 618 | channel: WS_CHANNELS.serverWelcome, |
| 584 | data: { cwd, projectName }, | |
| 619 | data: { | |
| 620 | cwd, | |
| 621 | projectName, | |
| 622 | ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), | |
| 623 | ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), | |
| 624 | }, | |
| 585 | 625 | }; |
| 586 | 626 | logOutgoingPush(welcome, 1); |
| 587 | 627 | ws.send(JSON.stringify(welcome)); |
apps/web/src/routes/__root.tsx+36-4| ... | ... | @@ -1,5 +1,11 @@ |
| 1 | 1 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | import { Outlet, createRootRouteWithContext, type ErrorComponentProps } from "@tanstack/react-router"; | |
| 2 | import { | |
| 3 | Outlet, | |
| 4 | createRootRouteWithContext, | |
| 5 | type ErrorComponentProps, | |
| 6 | useNavigate, | |
| 7 | useRouterState, | |
| 8 | } from "@tanstack/react-router"; | |
| 3 | 9 | import { useEffect, useRef } from "react"; |
| 4 | 10 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 5 | 11 | |
| ... | ... | @@ -119,7 +125,10 @@ function errorDetails(error: unknown): string { |
| 119 | 125 | function EventRouter() { |
| 120 | 126 | const { dispatch } = useStore(); |
| 121 | 127 | const queryClient = useQueryClient(); |
| 128 | const navigate = useNavigate(); | |
| 129 | const pathname = useRouterState({ select: (state) => state.location.pathname }); | |
| 122 | 130 | const lastConfigIssuesSignatureRef = useRef<string | null>(null); |
| 131 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | |
| 123 | 132 | |
| 124 | 133 | useEffect(() => { |
| 125 | 134 | const api = readNativeApi(); |
| ... | ... | @@ -180,8 +189,31 @@ function EventRouter() { |
| 180 | 189 | hasRunningSubprocess, |
| 181 | 190 | }); |
| 182 | 191 | }); |
| 183 | const unsubWelcome = onServerWelcome(() => { | |
| 184 | void syncSnapshot(); | |
| 192 | const unsubWelcome = onServerWelcome((payload) => { | |
| 193 | void (async () => { | |
| 194 | await syncSnapshot(); | |
| 195 | ||
| 196 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | |
| 197 | return; | |
| 198 | } | |
| 199 | dispatch({ | |
| 200 | type: "SET_PROJECT_EXPANDED", | |
| 201 | projectId: payload.bootstrapProjectId, | |
| 202 | expanded: true, | |
| 203 | }); | |
| 204 | if (pathname !== "/") { | |
| 205 | return; | |
| 206 | } | |
| 207 | if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { | |
| 208 | return; | |
| 209 | } | |
| 210 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | |
| 211 | await navigate({ | |
| 212 | to: "/$threadId", | |
| 213 | params: { threadId: payload.bootstrapThreadId }, | |
| 214 | replace: true, | |
| 215 | }); | |
| 216 | })().catch(() => undefined); | |
| 185 | 217 | }); |
| 186 | 218 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { |
| 187 | 219 | const signature = JSON.stringify(payload.issues); |
| ... | ... | @@ -232,7 +264,7 @@ function EventRouter() { |
| 232 | 264 | unsubWelcome(); |
| 233 | 265 | unsubServerConfigUpdated(); |
| 234 | 266 | }; |
| 235 | }, [dispatch, queryClient]); | |
| 267 | }, [dispatch, navigate, pathname, queryClient]); | |
| 236 | 268 | |
| 237 | 269 | return null; |
| 238 | 270 | } |
apps/web/src/store.ts+9| ... | ... | @@ -41,6 +41,7 @@ type Action = |
| 41 | 41 | terminalId: string; |
| 42 | 42 | hasRunningSubprocess: boolean; |
| 43 | 43 | } |
| 44 | | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean } | |
| 44 | 45 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } |
| 45 | 46 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } |
| 46 | 47 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } |
| ... | ... | @@ -571,6 +572,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 571 | 572 | ), |
| 572 | 573 | }; |
| 573 | 574 | |
| 575 | case "SET_PROJECT_EXPANDED": | |
| 576 | return { | |
| 577 | ...state, | |
| 578 | projects: state.projects.map((p) => | |
| 579 | p.id === action.projectId ? { ...p, expanded: action.expanded } : p, | |
| 580 | ), | |
| 581 | }; | |
| 582 | ||
| 574 | 583 | case "TOGGLE_THREAD_TERMINAL": |
| 575 | 584 | return { |
| 576 | 585 | ...state, |
apps/web/src/wsNativeApi.test.ts+30-3| ... | ... | @@ -65,16 +65,41 @@ describe("wsNativeApi", () => { |
| 65 | 65 | emitPush(WS_CHANNELS.serverWelcome, payload); |
| 66 | 66 | |
| 67 | 67 | expect(listener).toHaveBeenCalledTimes(1); |
| 68 | expect(listener).toHaveBeenCalledWith(payload); | |
| 68 | expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); | |
| 69 | 69 | |
| 70 | 70 | const lateListener = vi.fn(); |
| 71 | 71 | onServerWelcome(lateListener); |
| 72 | 72 | |
| 73 | 73 | expect(lateListener).toHaveBeenCalledTimes(1); |
| 74 | expect(lateListener).toHaveBeenCalledWith(payload); | |
| 74 | expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); | |
| 75 | 75 | expect(warnSpy).not.toHaveBeenCalled(); |
| 76 | 76 | }); |
| 77 | 77 | |
| 78 | it("preserves bootstrap ids from server.welcome payloads", async () => { | |
| 79 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | |
| 80 | ||
| 81 | createWsNativeApi(); | |
| 82 | const listener = vi.fn(); | |
| 83 | onServerWelcome(listener); | |
| 84 | ||
| 85 | emitPush(WS_CHANNELS.serverWelcome, { | |
| 86 | cwd: "/tmp/workspace", | |
| 87 | projectName: "t3-code", | |
| 88 | bootstrapProjectId: "project-1", | |
| 89 | bootstrapThreadId: "thread-1", | |
| 90 | }); | |
| 91 | ||
| 92 | expect(listener).toHaveBeenCalledTimes(1); | |
| 93 | expect(listener).toHaveBeenCalledWith( | |
| 94 | expect.objectContaining({ | |
| 95 | cwd: "/tmp/workspace", | |
| 96 | projectName: "t3-code", | |
| 97 | bootstrapProjectId: "project-1", | |
| 98 | bootstrapThreadId: "thread-1", | |
| 99 | }), | |
| 100 | ); | |
| 101 | }); | |
| 102 | ||
| 78 | 103 | it("ignores invalid server.welcome payloads and keeps subscription active", async () => { |
| 79 | 104 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 80 | 105 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); |
| ... | ... | @@ -87,7 +112,9 @@ describe("wsNativeApi", () => { |
| 87 | 112 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); |
| 88 | 113 | |
| 89 | 114 | expect(listener).toHaveBeenCalledTimes(1); |
| 90 | expect(listener).toHaveBeenCalledWith({ cwd: "/tmp/workspace", projectName: "t3-code" }); | |
| 115 | expect(listener).toHaveBeenCalledWith( | |
| 116 | expect.objectContaining({ cwd: "/tmp/workspace", projectName: "t3-code" }), | |
| 117 | ); | |
| 91 | 118 | expect(warnSpy).toHaveBeenCalledTimes(1); |
| 92 | 119 | expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { |
| 93 | 120 | reason: "decode-failed", |
packages/contracts/src/ws.ts+3-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { Schema, Struct } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| 2 | import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | ClientOrchestrationCommand, |
| ... | ... | @@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type; |
| 163 | 163 | export const WsWelcomePayload = Schema.Struct({ |
| 164 | 164 | cwd: TrimmedNonEmptyString, |
| 165 | 165 | projectName: TrimmedNonEmptyString, |
| 166 | bootstrapProjectId: Schema.optional(ProjectId), | |
| 167 | bootstrapThreadId: Schema.optional(ThreadId), | |
| 166 | 168 | }); |
| 167 | 169 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; |