| 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,23 +448,62 @@ describe("WebSocket Server", () => { |
| 448 | 448 | ||
| 449 | const ws = await connectWs(port); | 449 | const ws = await connectWs(port); |
| 450 | connections.push(ws); | 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 | const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); | 462 | const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); |
| 454 | expect(snapshotResponse.error).toBeUndefined(); | 463 | expect(snapshotResponse.error).toBeUndefined(); |
| 455 | const snapshot = snapshotResponse.result as { | 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 | expect(snapshot.projects).toEqual( | 485 | expect(snapshot.projects).toEqual( |
| 460 | expect.arrayContaining([ | 486 | expect.arrayContaining([ |
| 461 | expect.objectContaining({ | 487 | expect.objectContaining({ |
| 488 | id: bootstrapProjectId, | ||
| 462 | workspaceRoot: "/test/bootstrap-workspace", | 489 | workspaceRoot: "/test/bootstrap-workspace", |
| 463 | title: "bootstrap-workspace", | 490 | title: "bootstrap-workspace", |
| 464 | defaultModel: "gpt-5-codex", | 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 | it("logs outbound websocket push events in dev mode", async () => { | 509 | it("logs outbound websocket push events in dev mode", async () => { |
apps/server/src/wsServer.ts+47-7| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | ORCHESTRATION_WS_CHANNELS, | 16 | ORCHESTRATION_WS_CHANNELS, |
| 17 | ORCHESTRATION_WS_METHODS, | 17 | ORCHESTRATION_WS_METHODS, |
| 18 | ProjectId, | 18 | ProjectId, |
| 19 | ThreadId, | ||
| 19 | TerminalEvent, | 20 | TerminalEvent, |
| 20 | WS_CHANNELS, | 21 | WS_CHANNELS, |
| 21 | WS_METHODS, | 22 | WS_METHODS, |
| ... | @@ -337,22 +338,56 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -337,22 +338,56 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 337 | 338 | ||
| 338 | yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); | 339 | yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); |
| 339 | 340 | ||
| 341 | let welcomeBootstrapProjectId: ProjectId | undefined; | ||
| 342 | let welcomeBootstrapThreadId: ThreadId | undefined; | ||
| 343 | |||
| 340 | if (autoBootstrapProjectFromCwd) { | 344 | if (autoBootstrapProjectFromCwd) { |
| 341 | yield* Effect.gen(function* () { | 345 | yield* Effect.gen(function* () { |
| 342 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); | 346 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); |
| 343 | const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd); | 347 | const existingProject = snapshot.projects.find( |
| 344 | if (!existing) { | 348 | (project) => project.workspaceRoot === cwd && project.deletedAt === null, |
| 349 | ); | ||
| 350 | let bootstrapProjectId: ProjectId; | ||
| 351 | let bootstrapProjectDefaultModel: string; | ||
| 352 | |||
| 353 | if (!existingProject) { | ||
| 345 | const createdAt = new Date().toISOString(); | 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 | yield* orchestrationEngine.dispatch({ | 358 | yield* orchestrationEngine.dispatch({ |
| 348 | type: "project.create", | 359 | type: "project.create", |
| 349 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | 360 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), |
| 350 | projectId: ProjectId.makeUnsafe(crypto.randomUUID()), | 361 | projectId: bootstrapProjectId, |
| 351 | title: projectName, | 362 | title: bootstrapProjectTitle, |
| 352 | workspaceRoot: cwd, | 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 | createdAt, | 387 | createdAt, |
| 355 | }); | 388 | }); |
| 389 | welcomeBootstrapProjectId = bootstrapProjectId; | ||
| 390 | welcomeBootstrapThreadId = threadId; | ||
| 356 | } | 391 | } |
| 357 | }).pipe( | 392 | }).pipe( |
| 358 | Effect.mapError( | 393 | Effect.mapError( |
| ... | @@ -581,7 +616,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -581,7 +616,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 581 | const welcome: WsPush = { | 616 | const welcome: WsPush = { |
| 582 | type: "push", | 617 | type: "push", |
| 583 | channel: WS_CHANNELS.serverWelcome, | 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 | logOutgoingPush(welcome, 1); | 626 | logOutgoingPush(welcome, 1); |
| 587 | ws.send(JSON.stringify(welcome)); | 627 | ws.send(JSON.stringify(welcome)); |
apps/web/src/routes/__root.tsx+36-4| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | 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 | import { useEffect, useRef } from "react"; | 9 | import { useEffect, useRef } from "react"; |
| 4 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | 10 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 5 | 11 | ||
| ... | @@ -119,7 +125,10 @@ function errorDetails(error: unknown): string { | ... | @@ -119,7 +125,10 @@ function errorDetails(error: unknown): string { |
| 119 | function EventRouter() { | 125 | function EventRouter() { |
| 120 | const { dispatch } = useStore(); | 126 | const { dispatch } = useStore(); |
| 121 | const queryClient = useQueryClient(); | 127 | const queryClient = useQueryClient(); |
| 128 | const navigate = useNavigate(); | ||
| 129 | const pathname = useRouterState({ select: (state) => state.location.pathname }); | ||
| 122 | const lastConfigIssuesSignatureRef = useRef<string | null>(null); | 130 | const lastConfigIssuesSignatureRef = useRef<string | null>(null); |
| 131 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | ||
| 123 | 132 | ||
| 124 | useEffect(() => { | 133 | useEffect(() => { |
| 125 | const api = readNativeApi(); | 134 | const api = readNativeApi(); |
| ... | @@ -180,8 +189,31 @@ function EventRouter() { | ... | @@ -180,8 +189,31 @@ function EventRouter() { |
| 180 | hasRunningSubprocess, | 189 | hasRunningSubprocess, |
| 181 | }); | 190 | }); |
| 182 | }); | 191 | }); |
| 183 | const unsubWelcome = onServerWelcome(() => { | 192 | const unsubWelcome = onServerWelcome((payload) => { |
| 184 | void syncSnapshot(); | 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 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { | 218 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { |
| 187 | const signature = JSON.stringify(payload.issues); | 219 | const signature = JSON.stringify(payload.issues); |
| ... | @@ -232,7 +264,7 @@ function EventRouter() { | ... | @@ -232,7 +264,7 @@ function EventRouter() { |
| 232 | unsubWelcome(); | 264 | unsubWelcome(); |
| 233 | unsubServerConfigUpdated(); | 265 | unsubServerConfigUpdated(); |
| 234 | }; | 266 | }; |
| 235 | }, [dispatch, queryClient]); | 267 | }, [dispatch, navigate, pathname, queryClient]); |
| 236 | 268 | ||
| 237 | return null; | 269 | return null; |
| 238 | } | 270 | } |
apps/web/src/store.ts+9| ... | @@ -41,6 +41,7 @@ type Action = | ... | @@ -41,6 +41,7 @@ type Action = |
| 41 | terminalId: string; | 41 | terminalId: string; |
| 42 | hasRunningSubprocess: boolean; | 42 | hasRunningSubprocess: boolean; |
| 43 | } | 43 | } |
| 44 | | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean } | ||
| 44 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } | 45 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } |
| 45 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } | 46 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } |
| 46 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } | 47 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } |
| ... | @@ -571,6 +572,14 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -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 | case "TOGGLE_THREAD_TERMINAL": | 583 | case "TOGGLE_THREAD_TERMINAL": |
| 575 | return { | 584 | return { |
| 576 | ...state, | 585 | ...state, |
apps/web/src/wsNativeApi.test.ts+30-3| ... | @@ -65,16 +65,41 @@ describe("wsNativeApi", () => { | ... | @@ -65,16 +65,41 @@ describe("wsNativeApi", () => { |
| 65 | emitPush(WS_CHANNELS.serverWelcome, payload); | 65 | emitPush(WS_CHANNELS.serverWelcome, payload); |
| 66 | 66 | ||
| 67 | expect(listener).toHaveBeenCalledTimes(1); | 67 | expect(listener).toHaveBeenCalledTimes(1); |
| 68 | expect(listener).toHaveBeenCalledWith(payload); | 68 | expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload)); |
| 69 | 69 | ||
| 70 | const lateListener = vi.fn(); | 70 | const lateListener = vi.fn(); |
| 71 | onServerWelcome(lateListener); | 71 | onServerWelcome(lateListener); |
| 72 | 72 | ||
| 73 | expect(lateListener).toHaveBeenCalledTimes(1); | 73 | expect(lateListener).toHaveBeenCalledTimes(1); |
| 74 | expect(lateListener).toHaveBeenCalledWith(payload); | 74 | expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); |
| 75 | expect(warnSpy).not.toHaveBeenCalled(); | 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 | it("ignores invalid server.welcome payloads and keeps subscription active", async () => { | 103 | it("ignores invalid server.welcome payloads and keeps subscription active", async () => { |
| 79 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | 104 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 80 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | 105 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); |
| ... | @@ -87,7 +112,9 @@ describe("wsNativeApi", () => { | ... | @@ -87,7 +112,9 @@ describe("wsNativeApi", () => { |
| 87 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); | 112 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); |
| 88 | 113 | ||
| 89 | expect(listener).toHaveBeenCalledTimes(1); | 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 | expect(warnSpy).toHaveBeenCalledTimes(1); | 118 | expect(warnSpy).toHaveBeenCalledTimes(1); |
| 92 | expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { | 119 | expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { |
| 93 | reason: "decode-failed", | 120 | reason: "decode-failed", |
packages/contracts/src/ws.ts+3-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Schema, Struct } from "effect"; | 1 | import { Schema, Struct } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | ClientOrchestrationCommand, | 5 | ClientOrchestrationCommand, |
| ... | @@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type; | ... | @@ -163,5 +163,7 @@ export type WsResponse = typeof WsResponse.Type; |
| 163 | export const WsWelcomePayload = Schema.Struct({ | 163 | export const WsWelcomePayload = Schema.Struct({ |
| 164 | cwd: TrimmedNonEmptyString, | 164 | cwd: TrimmedNonEmptyString, |
| 165 | projectName: TrimmedNonEmptyString, | 165 | projectName: TrimmedNonEmptyString, |
| 166 | bootstrapProjectId: Schema.optional(ProjectId), | ||
| 167 | bootstrapThreadId: Schema.optional(ThreadId), | ||
| 166 | }); | 168 | }); |
| 167 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; | 169 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; |