diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 03:26:11 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 03:26:11 +0900 |
| commit | 69f89ab49be842d9826fb0b1621cc8c8dea5f14c (patch) | |
| tree | 8e67fc59a74d2529c9d675cdd295d4f22642f2d6 /packages | |
| parent | 41ca91a959ae245e76e50f1b55c8b21592bc6c50 (diff) | |
| download | dispatch-69f89ab49be842d9826fb0b1621cc8c8dea5f14c.tar.gz dispatch-69f89ab49be842d9826fb0b1621cc8c8dea5f14c.zip | |
workspace: conversation.open/statusChanged carry workspaceId (1405 vitest)
- @dispatch/transport-contract 0.18.0 -> 0.19.0:
add workspaceId: string to ConversationOpenMessage and ConversationStatusChangedMessage
- session-orchestrator: include persisted workspaceId in conversationOpened/
conversationStatusChanged payloads
- transport-ws: forward workspaceId in WS broadcasts
- transport-http: POST /conversations/:id/open resolves workspaceId before emit
- FE handoff to 29ae: frontend-workspace-open-handoff.md
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 153 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 45 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 2 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 12 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 12 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 11 | ||||
| -rw-r--r-- | packages/transport-ws/src/extension.ts | 23 | ||||
| -rw-r--r-- | packages/transport-ws/src/server.bun.test.ts | 118 |
8 files changed, 330 insertions, 46 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 18a4a62..3b79bd5 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -19,6 +19,8 @@ import { runTurn } from "@dispatch/kernel"; import type { SystemPromptService } from "@dispatch/system-prompt"; import { describe, expect, it } from "vitest"; import { + type ConversationOpenedPayload, + type ConversationStatusChangedPayload, createCompactionService, createSessionOrchestrator, createWarmService, @@ -1228,23 +1230,48 @@ describe("lifecycle event hooks", () => { modelName: "mymodel", }); - expect(emitted).toHaveLength(4); - expect(emitted[0]?.hook).toBe("session-orchestrator/turn-started"); - expect(emitted[0]?.payload.conversationId).toBe("conv-lifecycle"); - expect(emitted[0]?.payload.cwd).toBe("/work"); - expect(emitted[0]?.payload.modelName).toBe("mymodel"); - expect(emitted[0]?.order).toBe(0); + // The status-changed emits resolve the persisted workspace id async + // (getWorkspaceId) before firing, so they may land after turn-settled + // in microtask order. Flush all pending microtasks so every emit has + // landed, then assert by hook identity rather than strict index order. + await new Promise((resolve) => setImmediate(resolve)); - expect(emitted[1]?.hook).toBe("session-orchestrator/conversation-status-changed"); - expect((emitted[1]?.payload as unknown as { status: string }).status).toBe("active"); + expect(emitted).toHaveLength(4); - expect(emitted[2]?.hook).toBe("session-orchestrator/turn-settled"); - expect(emitted[2]?.payload.conversationId).toBe("conv-lifecycle"); - expect(emitted[2]?.payload.cwd).toBe("/work"); - expect(emitted[2]?.payload.modelName).toBe("mymodel"); + const started = emitted.find((e) => e.hook === "session-orchestrator/turn-started"); + const settled = emitted.find((e) => e.hook === "session-orchestrator/turn-settled"); + const statusChanges = emitted.filter( + (e) => e.hook === "session-orchestrator/conversation-status-changed", + ); - expect(emitted[3]?.hook).toBe("session-orchestrator/conversation-status-changed"); - expect((emitted[3]?.payload as unknown as { status: string }).status).toBe("idle"); + expect(started).toBeDefined(); + expect(started?.payload.conversationId).toBe("conv-lifecycle"); + expect(started?.payload.cwd).toBe("/work"); + expect(started?.payload.modelName).toBe("mymodel"); + // turn-started is the FIRST emit (synchronous, before any async deferral). + expect(started?.order).toBe(0); + + expect(settled).toBeDefined(); + expect(settled?.payload.conversationId).toBe("conv-lifecycle"); + expect(settled?.payload.cwd).toBe("/work"); + expect(settled?.payload.modelName).toBe("mymodel"); + // turn-started precedes turn-settled. + expect(started?.order).toBeLessThan(settled?.order ?? Infinity); + + expect(statusChanges).toHaveLength(2); + const activeChange = statusChanges.find( + (e) => (e.payload as unknown as { status: string }).status === "active", + ); + const idleChange = statusChanges.find( + (e) => (e.payload as unknown as { status: string }).status === "idle", + ); + expect(activeChange).toBeDefined(); + expect(idleChange).toBeDefined(); + // Both status-changed payloads now carry the persisted workspace id. + expect((activeChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe( + "default", + ); + expect((idleChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe("default"); }); }); @@ -2402,7 +2429,7 @@ describe("closeConversation (CR-4c)", () => { expect(persisted[0]?.role).toBe("user"); }); - it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", () => { + it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", async () => { const store = createInMemoryStore(); const emitted: Array<{ hook: string; payload: unknown }> = []; const { orchestrator } = createSessionOrchestrator({ @@ -2418,22 +2445,110 @@ describe("closeConversation (CR-4c)", () => { const result = orchestrator.closeConversation("conv-never-seen"); expect(result.abortedTurn).toBe(false); + // The conversation-closed hook is emitted synchronously; the + // status-changed hook resolves the workspace id async before emitting. expect(emitted).toEqual([ { hook: "session-orchestrator/conversation-closed", payload: { conversationId: "conv-never-seen" }, }, - { - hook: "session-orchestrator/conversation-status-changed", - payload: { conversationId: "conv-never-seen", status: "closed" }, - }, ]); + // Flush the async getWorkspaceId resolution so the status-changed emit lands. + await new Promise((resolve) => setImmediate(resolve)); + expect(emitted).toContainEqual({ + hook: "session-orchestrator/conversation-status-changed", + payload: { conversationId: "conv-never-seen", status: "closed", workspaceId: "default" }, + }); // Closing again is still safe. expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false); }); }); +// --- workspace id on conversationOpened / conversationStatusChanged payloads --- + +describe("workspace id broadcast payloads", () => { + it("conversationStatusChanged payload carries the workspace id from the store", async () => { + const base = createInMemoryStore(); + // Pre-assign a non-default workspace so we can assert it's threaded + // through (not the per-turn start option, which differs). + await base.setWorkspaceId("conv-ws-broadcast", "team-workspace"); + + const emitted: Array<{ hook: string; payload: ConversationStatusChangedPayload }> = []; + const provider = createFakeProvider([ + [ + { type: "text-delta", delta: "ok" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: base, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook, payload) => { + if (hook.id === "session-orchestrator/conversation-status-changed") { + emitted.push({ + hook: hook.id, + payload: payload as ConversationStatusChangedPayload, + }); + } + }, + }); + + // Pass a DIFFERENT per-turn workspaceId to prove the payload uses the + // persisted store value, not the start option. + await orchestrator.handleMessage({ + conversationId: "conv-ws-broadcast", + text: "hi", + onEvent: () => {}, + workspaceId: "should-not-appear", + }); + // Flush the async getWorkspaceId resolutions. + await new Promise((resolve) => setImmediate(resolve)); + + expect(emitted.length).toBeGreaterThanOrEqual(2); + for (const e of emitted) { + expect(e.payload.workspaceId).toBe("team-workspace"); + } + + // closeConversation also threads the persisted workspace id. + const closeEmitted: ConversationStatusChangedPayload[] = []; + const { orchestrator: orchestrator2 } = createSessionOrchestrator({ + conversationStore: base, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + emit: (hook, payload) => { + if (hook.id === "session-orchestrator/conversation-status-changed") { + closeEmitted.push(payload as ConversationStatusChangedPayload); + } + }, + }); + orchestrator2.closeConversation("conv-ws-broadcast"); + await new Promise((resolve) => setImmediate(resolve)); + const closed = closeEmitted.find((p) => p.status === "closed"); + expect(closed).toBeDefined(); + expect(closed?.workspaceId).toBe("team-workspace"); + }); + + it("conversationOpened payload carries the workspace id (type-level construct)", () => { + // conversationOpened is emitted by a sibling transport unit, so this + // package only owns the payload TYPE. This regression test pins the + // type to require workspaceId (a missing field would fail to compile) + // and verifies the persisted value flows through at construction time. + const payload: ConversationOpenedPayload = { + conversationId: "conv-open", + workspaceId: "open-workspace", + }; + expect(payload.workspaceId).toBe("open-workspace"); + expect(payload.conversationId).toBe("conv-open"); + }); +}); + describe("reasoning effort resolution", () => { it("override wins over stored → provider receives the override level", async () => { const store = createInMemoryStore(); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 7599a0c..1403288 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -113,6 +113,12 @@ export const conversationClosed: EventHookDescriptor<ConversationClosedPayload> /** Payload for the conversationOpened bus event. */ export interface ConversationOpenedPayload { readonly conversationId: string; + /** + * The conversation's actual persisted workspace id (resolved from the + * store, not the per-turn start option), so a frontend can open/focus the + * tab in the correct workspace. Falls back to `"default"`. + */ + readonly workspaceId: string; } /** @@ -128,6 +134,12 @@ export const conversationOpened: EventHookDescriptor<ConversationOpenedPayload> export interface ConversationStatusChangedPayload { readonly conversationId: string; readonly status: ConversationStatus; + /** + * The conversation's actual persisted workspace id (resolved from the + * store, not the per-turn start option), so a frontend can sync the tab + * in the correct workspace. Falls back to `"default"`. + */ + readonly workspaceId: string; } /** @@ -410,7 +422,15 @@ export function createSessionOrchestrator( payloadPromise.then((payload) => { deps.emit?.(turnStarted, payload); - deps.emit?.(conversationStatusChanged, { conversationId, status: "active" }); + // Resolve the persisted workspace id (not the per-turn start option) + // before emitting so the broadcast carries the correct workspace. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "active", + workspaceId, + }); + }); void deps.conversationStore.setConversationStatus(conversationId, "active"); }); @@ -595,9 +615,14 @@ export function createSessionOrchestrator( void payloadPromise.then((payload) => { deps.emit?.(turnSettled, payload); if (!carried) { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "idle", + // Resolve the persisted workspace id before emitting so the + // broadcast carries the correct workspace. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "idle", + workspaceId, + }); }); void deps.conversationStore.setConversationStatus(conversationId, "idle"); // Fire-and-forget auto-compaction: check threshold and @@ -691,7 +716,17 @@ export function createSessionOrchestrator( turn.controller.abort(); } deps.emit?.(conversationClosed, { conversationId }); - deps.emit?.(conversationStatusChanged, { conversationId, status: "closed" }); + // Resolve the persisted workspace id before emitting so the + // broadcast carries the correct workspace. The hook is + // fire-and-forget; closeConversation stays synchronous (returns + // immediately) while the status-changed emit resolves async. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "closed", + workspaceId, + }); + }); void deps.conversationStore.setConversationStatus(conversationId, "closed"); return { abortedTurn }; }, diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 542ed90..71baa43 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.18.0", + "version": "0.19.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 2a3bb9f..fcbd1b1 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -604,6 +604,12 @@ export type WsServerMessage = export interface ConversationOpenMessage { readonly type: "conversation.open"; readonly conversationId: string; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** @@ -615,6 +621,12 @@ export interface ConversationStatusChangedMessage { readonly type: "conversation.statusChanged"; readonly conversationId: string; readonly status: ConversationStatus; + /** + * The conversation's actual workspace id, so a frontend can open/focus it + * in the correct workspace instead of stamping it with the viewer's current + * workspace. + */ + readonly workspaceId: string; } /** diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 2a4b451..7887695 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -2814,8 +2814,13 @@ describe("POST /conversations/:id/open", () => { const emit: HostAPI["emit"] = (hook, payload) => { emitCalls.push({ hook, payload }); }; + // A store whose getWorkspaceId returns a non-default id, so the test + // proves the handler resolves and forwards the PERSISTED workspace id + // (not a hard-coded "default"). + const store = createFakeConversationStore(); + store.getWorkspaceId = async () => "open-workspace"; const app = createApp({ - conversationStore: createFakeConversationStore(), + conversationStore: store, orchestrator: createFakeOrchestrator([]), credentialStore: createFakeCredentialStore([]), emit, @@ -2825,7 +2830,10 @@ describe("POST /conversations/:id/open", () => { expect(res.status).toBe(200); expect(emitCalls).toHaveLength(1); expect(emitCalls[0]?.hook).toBe(conversationOpened); - expect(emitCalls[0]?.payload).toEqual({ conversationId: "conv1" }); + expect(emitCalls[0]?.payload).toEqual({ + conversationId: "conv1", + workspaceId: "open-workspace", + }); }); it("returns 500 when emit is absent", async () => { diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 41b583c..bc8b9de 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -767,7 +767,7 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json(body, 200); }); - app.post("/conversations/:id/open", (c) => { + app.post("/conversations/:id/open", async (c) => { const conversationId = c.req.param("id"); if (opts.emit === undefined) { log.warn("conversations: open requested but emit is not available", { @@ -775,8 +775,13 @@ export function createApp(opts: CreateServerOptions): Hono { }); return c.json({ error: "not available" }, 500); } - opts.emit(conversationOpened, { conversationId }); - log.info("conversations: opened", { conversationId }); + // Resolve the conversation's persisted workspace id so the frontend can + // open/focus the tab in the correct workspace. The store falls back to + // `"default"` when no workspaceId is persisted (or the conversation is + // unknown), so this never throws for a missing conversation. + const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId); + opts.emit(conversationOpened, { conversationId, workspaceId }); + log.info("conversations: opened", { conversationId, workspaceId }); const body: OpenConversationResponse = { conversationId }; return c.json(body, 200); }); diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 899dabb..1e3da27 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -133,18 +133,29 @@ export function createTransportWsExtension(): Extension { // whenever the orchestrator signals a conversation was opened (e.g. the // CLI `--open` flag). The frontend decides whether to open/focus a tab — // the backend just signals. This is a GLOBAL fan-out (like the catalog), - // NOT a per-conversation chat broadcast. + // NOT a per-conversation chat broadcast. The payload's `workspaceId` + // is the conversation's actual persisted workspace (resolved by the + // orchestrator from the store), so a frontend opens/focuses the tab in + // the correct workspace. disposers.push( - host.on(conversationOpened, ({ conversationId }) => { - broadcast({ type: "conversation.open", conversationId }); + host.on(conversationOpened, ({ conversationId, workspaceId }) => { + broadcast({ type: "conversation.open", conversationId, workspaceId }); }), ); // Broadcast `conversation.statusChanged` to all connected clients so - // tabs sync across devices in real time. + // tabs sync across devices in real time. `workspaceId` is the + // conversation's actual persisted workspace (resolved by the + // orchestrator from the store), forwarded so a frontend syncs the tab + // in the correct workspace. disposers.push( - host.on(conversationStatusChanged, ({ conversationId, status }) => { - broadcast({ type: "conversation.statusChanged", conversationId, status }); + host.on(conversationStatusChanged, ({ conversationId, status, workspaceId }) => { + broadcast({ + type: "conversation.statusChanged", + conversationId, + status, + workspaceId, + }); }), ); diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts index 3a1bf03..e24aa6b 100644 --- a/packages/transport-ws/src/server.bun.test.ts +++ b/packages/transport-ws/src/server.bun.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { AgentEvent, Attributes, ErrorAttributes, Logger } from "@dispatch/kernel"; import type { SessionOrchestrator, TurnEventListener } from "@dispatch/session-orchestrator"; import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; -import type { WsServerMessage } from "@dispatch/transport-contract"; +import type { ConversationStatus, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceCatalogEntry, SurfaceClientMessage, SurfaceSpec } from "@dispatch/ui-contract"; import { catalogMessage, routeClientMessage, subKey } from "./router.js"; @@ -445,11 +445,30 @@ function startServer( /** * Simulate the `conversationOpened` hook firing — mirrors the * `host.on(conversationOpened, ...)` subscription in extension.ts, which - * broadcasts a `conversation.open` WS message to every connected client. + * broadcasts a `conversation.open` WS message (carrying the conversation's + * persisted `workspaceId`) to every connected client. */ return Object.assign(server, { - triggerConversationOpen(conversationId: string): void { - broadcast({ type: "conversation.open", conversationId }); + triggerConversationOpen(conversationId: string, workspaceId: string): void { + broadcast({ type: "conversation.open", conversationId, workspaceId }); + }, + /** + * Simulate the `conversationStatusChanged` hook firing — mirrors the + * `host.on(conversationStatusChanged, ...)` subscription in extension.ts, + * which broadcasts a `conversation.statusChanged` WS message (carrying the + * conversation's persisted `workspaceId`) to every connected client. + */ + triggerConversationStatusChanged( + conversationId: string, + status: ConversationStatus, + workspaceId: string, + ): void { + broadcast({ + type: "conversation.statusChanged", + conversationId, + status, + workspaceId, + }); }, }); } @@ -1079,10 +1098,14 @@ describe("conversation.open broadcast (conversationOpened hook)", () => { // Simulate the conversationOpened hook firing (extension.ts's // `host.on(conversationOpened, ...)` handler runs and broadcasts). - server.triggerConversationOpen("conv-42"); + server.triggerConversationOpen("conv-42", "ws-7"); const msg = await waitForMessage(ws); - expect(msg).toEqual({ type: "conversation.open", conversationId: "conv-42" }); + expect(msg).toEqual({ + type: "conversation.open", + conversationId: "conv-42", + workspaceId: "ws-7", + }); ws.close(); }); @@ -1099,12 +1122,87 @@ describe("conversation.open broadcast (conversationOpened hook)", () => { await waitForMessage(ws2); // drain catalog // Global fan-out: BOTH connected clients receive the broadcast, - // regardless of any per-conversation subscription state. - server.triggerConversationOpen("shared-conv"); + // regardless of any per-conversation subscription state. The forwarded + // `workspaceId` is identical on both. + server.triggerConversationOpen("shared-conv", "ws-shared"); + + const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); + expect(msg1).toEqual({ + type: "conversation.open", + conversationId: "shared-conv", + workspaceId: "ws-shared", + }); + expect(msg2).toEqual({ + type: "conversation.open", + conversationId: "shared-conv", + workspaceId: "ws-shared", + }); + + ws1.close(); + ws2.close(); + }); +}); + +describe("conversation.statusChanged broadcast (conversationStatusChanged hook)", () => { + let server: ReturnType<typeof startServer>; + let port: number; + + afterEach(() => { + server.stop(); + }); + + test("conversation.statusChanged broadcast forwards workspaceId", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); + server = startServer(registry, orch); + port = server.port as number; + + const ws = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws); // drain catalog + + // Simulate the conversationStatusChanged hook firing (extension.ts's + // `host.on(conversationStatusChanged, ...)` handler runs and broadcasts). + server.triggerConversationStatusChanged("conv-9", "active", "ws-9"); + + const msg = await waitForMessage(ws); + expect(msg).toEqual({ + type: "conversation.statusChanged", + conversationId: "conv-9", + status: "active", + workspaceId: "ws-9", + }); + + ws.close(); + }); + + test("conversation.statusChanged sent to all connected clients with the same workspaceId", async () => { + const orch = fakeOrchestrator(); + const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]); + server = startServer(registry, orch); + port = server.port as number; + + const ws1 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws1); // drain catalog + const ws2 = new WebSocket(`ws://localhost:${port}`); + await waitForMessage(ws2); // drain catalog + + // Global fan-out: BOTH connected clients receive the broadcast with the + // conversation's persisted workspaceId forwarded unchanged. + server.triggerConversationStatusChanged("shared-conv", "idle", "ws-shared"); const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]); - expect(msg1).toEqual({ type: "conversation.open", conversationId: "shared-conv" }); - expect(msg2).toEqual({ type: "conversation.open", conversationId: "shared-conv" }); + expect(msg1).toEqual({ + type: "conversation.statusChanged", + conversationId: "shared-conv", + status: "idle", + workspaceId: "ws-shared", + }); + expect(msg2).toEqual({ + type: "conversation.statusChanged", + conversationId: "shared-conv", + status: "idle", + workspaceId: "ws-shared", + }); ws1.close(); ws2.close(); |
