mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-31 01:44:52 +00:00
refactor(events): preserve native V2 envelopes
The instance bridge duplicated every OpenCode event payload into a synthetic properties field because server-side auto-accept still consumed the V1-style envelope. This expanded the SSE contract and left UI deletion handling with legacy fallbacks. Move auto-accept and session deletion to native event.data, publish OpenCode events unchanged, type the shared stream as OpenCodeEvent, and guard PTY consumers before narrowing their event union. Internal CodeNomad worktree events remain separate. Validated with server and UI typechecks, 51 targeted server tests, 21 targeted UI tests, 304 passing server tests with 3 platform skips, 535 UI tests, and git diff checks.
This commit is contained in:
parent
3bfaaafc51
commit
cfb970dc56
9 changed files with 39 additions and 60 deletions
|
|
@ -6,6 +6,7 @@ import type {
|
|||
Preferences,
|
||||
RecentFolder,
|
||||
} from "./config/schema"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
|
||||
/**
|
||||
* Canonical HTTP/SSE contract for the CLI server.
|
||||
|
|
@ -278,11 +279,7 @@ export interface InstanceData {
|
|||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
export type InstanceStreamEvent = OpenCodeEvent
|
||||
|
||||
export type SideCarKind = "port"
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,15 @@ function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record<s
|
|||
: event.type === "permission.v2.replied"
|
||||
? "permission.replied"
|
||||
: event.type
|
||||
bus.publish({ type: "instance.event", instanceId, event: { ...event, type } as InstanceStreamEvent })
|
||||
const { properties, ...nativeEvent } = event
|
||||
const wrapped = properties as { info?: Record<string, unknown> } | undefined
|
||||
const data = event.data ?? (wrapped?.info
|
||||
? { ...wrapped.info, sessionID: wrapped.info.sessionID ?? wrapped.info.id }
|
||||
: properties)
|
||||
bus.publish({ type: "instance.event", instanceId, event: { ...nativeEvent, type, data } as InstanceStreamEvent })
|
||||
}
|
||||
|
||||
/** Publish session creation using the compatibility shape produced by InstanceEventBridge. */
|
||||
/** Publish session lifecycle events using the native V2 data envelope. */
|
||||
function publishSession(
|
||||
bus: EventBus,
|
||||
instanceId: string,
|
||||
|
|
@ -38,7 +43,7 @@ function publishSession(
|
|||
) {
|
||||
publishInstanceEvent(bus, instanceId, {
|
||||
type: eventType === "session.updated" ? "session.created" : eventType,
|
||||
properties: { info: { ...info } },
|
||||
data: { ...info, sessionID: info.sessionID ?? info.id },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -258,16 +258,16 @@ export class AutoAcceptManager {
|
|||
if (!event || typeof event.type !== "string") return
|
||||
|
||||
if (SESSION_UPSERT_TYPES.has(event.type)) {
|
||||
this.ingestSession(instanceId, event.properties)
|
||||
this.ingestSession(instanceId, event.data)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.forked") {
|
||||
this.ingestSessionForked(instanceId, event.properties)
|
||||
this.ingestSessionForked(instanceId, event.data)
|
||||
return
|
||||
}
|
||||
if (SESSION_REMOVE_TYPES.has(event.type)) {
|
||||
const info = (event.properties as { info?: SessionProperties } | undefined)?.info
|
||||
const id = readString(info?.id) ?? readString(event.properties?.id)
|
||||
const data = event.data as SessionProperties | undefined
|
||||
const id = readString(data?.sessionID) ?? readString(data?.id)
|
||||
if (id) {
|
||||
this.store.removeSession(instanceId, id)
|
||||
this.removePendingForSession(instanceId, id)
|
||||
|
|
@ -275,29 +275,24 @@ export class AutoAcceptManager {
|
|||
return
|
||||
}
|
||||
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
|
||||
this.handlePermissionReplied(instanceId, event.properties)
|
||||
this.handlePermissionReplied(instanceId, event.data)
|
||||
return
|
||||
}
|
||||
if (PERMISSION_ASK_TYPES.has(event.type)) {
|
||||
this.handlePermissionRequest(instanceId, event.properties)
|
||||
this.handlePermissionRequest(instanceId, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
private ingestSession(instanceId: string, properties: unknown): void {
|
||||
// OpenCode wraps session records under `properties.info` for
|
||||
// session.created/updated/deleted (see SDK EventSessionUpdated). Accept a
|
||||
// flat fallback only for defensive compatibility.
|
||||
const info = (properties as { info?: SessionProperties } | SessionProperties | undefined)
|
||||
const session = (info && typeof info === "object" && "info" in info ? info.info : info) as
|
||||
| SessionProperties
|
||||
| undefined
|
||||
if (!session || typeof session.id !== "string") return
|
||||
private ingestSession(instanceId: string, data: unknown): void {
|
||||
const session = data as SessionProperties | undefined
|
||||
const sessionId = readString(session?.sessionID) ?? readString(session?.id)
|
||||
if (!session || !sessionId) return
|
||||
const parentId = session.parentID ?? session.parentId ?? null
|
||||
const enabledBefore = this.store.enabledRoots(instanceId)
|
||||
this.store.upsertSession(instanceId, { id: session.id, parentId, fork: session.fork })
|
||||
this.store.upsertSession(instanceId, { id: sessionId, parentId, fork: session.fork })
|
||||
if (typeof session.workspaceID === "string" && session.workspaceID) {
|
||||
const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map<string, string>()
|
||||
workspaces.set(session.id, session.workspaceID)
|
||||
workspaces.set(sessionId, session.workspaceID)
|
||||
this.sessionWorkspaces.set(instanceId, workspaces)
|
||||
}
|
||||
this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId))
|
||||
|
|
@ -305,7 +300,7 @@ export class AutoAcceptManager {
|
|||
// Re-drain pending permissions whose family root may have migrated into
|
||||
// an enabled family — mirrors the old UI's drainAutoAcceptPermissions-
|
||||
// ForInstance trigger from the previous UI implementation (#497).
|
||||
this.drainPending(instanceId, session.id)
|
||||
this.drainPending(instanceId, sessionId)
|
||||
}
|
||||
|
||||
private ingestSessionForked(instanceId: string, properties: unknown): void {
|
||||
|
|
@ -484,11 +479,12 @@ export class AutoAcceptManager {
|
|||
|
||||
interface InstanceStreamPayload {
|
||||
type?: string
|
||||
properties?: Record<string, unknown>
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
interface SessionProperties {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
parentID?: string | null
|
||||
parentId?: string | null
|
||||
fork?: unknown
|
||||
|
|
|
|||
|
|
@ -176,18 +176,18 @@ describe("InstanceEventBridge", () => {
|
|||
assert.equal(received[0].instanceId, "a")
|
||||
assert.deepEqual(received[0].event.location, { directory: "/repo-a" })
|
||||
assert.deepEqual(received[0].event.data, { id: "p1" })
|
||||
assert.deepEqual(received[0].event.properties, { id: "p1" })
|
||||
assert.equal(received[0].event.properties, undefined)
|
||||
assert.equal(received[1].event.data.sessionID, "session-1")
|
||||
assert.equal(received[1].event.properties.info.id, "session-1")
|
||||
assert.equal(received[1].event.properties, undefined)
|
||||
assert.equal(received[2].instanceId, "a")
|
||||
assert.deepEqual(received[2].event.properties, {
|
||||
assert.deepEqual(received[2].event.data, {
|
||||
sessionID: "session-2",
|
||||
assistantMessageID: "message-1",
|
||||
ordinal: 0,
|
||||
delta: "hello",
|
||||
})
|
||||
assert.equal(received[3].instanceId, "a")
|
||||
assert.equal(received[3].event.properties.delta, " again")
|
||||
assert.equal(received[3].event.data.delta, " again")
|
||||
assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2)
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
|
|
@ -294,7 +294,7 @@ describe("InstanceEventBridge", () => {
|
|||
await waitFor(() => received.length === 2)
|
||||
assert.equal(sessionGets(), 1)
|
||||
assert.deepEqual(received.map((event) => event.instanceId), ["a", "b"])
|
||||
assert.deepEqual(received.map((event) => event.event.properties.id), ["deleted", "deleted"])
|
||||
assert.deepEqual(received.map((event) => event.event.data.sessionID), ["deleted", "deleted"])
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ describe("InstanceEventBridge", () => {
|
|||
await waitFor(() => received.length === 1)
|
||||
assert.equal(sessionGets(), 1)
|
||||
assert.equal(received[0].instanceId, "b")
|
||||
assert.equal(received[0].event.properties.form.sessionID, "owned")
|
||||
assert.equal(received[0].event.data.form.sessionID, "owned")
|
||||
} finally {
|
||||
bridge.shutdown()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { OpenCodeEvent } from "@opencode-ai/client"
|
|||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "./manager"
|
||||
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||
import { InstanceStreamStatus } from "../api-types"
|
||||
|
||||
const RECONNECT_DELAY_MS = 1000
|
||||
const DIRECTORY_OWNER_CACHE_MS = 2000
|
||||
|
|
@ -128,13 +128,8 @@ export class InstanceEventBridge {
|
|||
return
|
||||
}
|
||||
|
||||
// The server's auto-accept boundary still reads the legacy property name.
|
||||
const compatibleEvent: InstanceStreamEvent = {
|
||||
...event,
|
||||
properties: this.compatibilityProperties(event),
|
||||
}
|
||||
for (const instanceId of instanceIds) {
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent })
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId, event })
|
||||
}
|
||||
if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId)
|
||||
if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId)
|
||||
|
|
@ -160,12 +155,8 @@ export class InstanceEventBridge {
|
|||
}
|
||||
|
||||
private broadcastEvent(event: OpenCodeEvent): void {
|
||||
const compatibleEvent: InstanceStreamEvent = {
|
||||
...event,
|
||||
properties: this.compatibilityProperties(event),
|
||||
}
|
||||
for (const workspace of this.options.workspaceManager.list()) {
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event: compatibleEvent })
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,16 +200,6 @@ export class InstanceEventBridge {
|
|||
this.ptyDirectories.clear()
|
||||
}
|
||||
|
||||
private compatibilityProperties(event: OpenCodeEvent): Record<string, unknown> {
|
||||
if (event.type === "session.created") {
|
||||
return { info: { ...event.data, id: event.data.sessionID } }
|
||||
}
|
||||
if (event.type === "session.deleted") {
|
||||
return { id: event.data.sessionID }
|
||||
}
|
||||
return event.data as Record<string, unknown>
|
||||
}
|
||||
|
||||
private updateStatus(status: InstanceStreamStatus, reason?: string) {
|
||||
this.status = status
|
||||
for (const workspace of this.options.workspaceManager.list()) {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ export interface WorktreeReadyEvent {
|
|||
export interface EventSessionDeleted {
|
||||
type: "session.deleted"
|
||||
data?: { sessionID?: string }
|
||||
properties?: { info?: { id?: string }; id?: string; sessionID?: string }
|
||||
}
|
||||
|
||||
type SSEEvent =
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ describe("instance runtime authority", () => {
|
|||
for (const test of [
|
||||
{ label: "direct definitive session removal", remove: removeSessionRuntimeState },
|
||||
{ label: "session.deleted event", remove: (id: string, sessionId: string) => handleSessionDeleted(id,
|
||||
{ type: "session.deleted", properties: { info: { id: sessionId } } }) },
|
||||
{ type: "session.deleted", data: { sessionID: sessionId } }) },
|
||||
]) it(`removes attachment authority on ${test.label}`, () => {
|
||||
const id = `authority-${test.label}`, sessionId = "deleted-session"
|
||||
addAttachment(id, sessionId, createTextAttachment("pasted", "pasted #1", "paste.txt"))
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { serverEvents } from "../lib/server-events"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { createPtyApi, createPtyStore } from "./pty-store"
|
||||
import { createPtyApi, createPtyStore, type PtyRefreshEvent } from "./pty-store"
|
||||
|
||||
const ptyStore = createPtyStore((instanceId) => createPtyApi(getRootClient(instanceId)))
|
||||
|
||||
serverEvents.on("instance.event", (event) => {
|
||||
if (event.type !== "instance.event") return
|
||||
void ptyStore.refreshForEvent(event.instanceId, event.event)
|
||||
if (!event.event.type.startsWith("pty.")) return
|
||||
void ptyStore.refreshForEvent(event.instanceId, event.event as PtyRefreshEvent)
|
||||
})
|
||||
|
||||
serverEvents.on("instance.eventStatus", (event) => {
|
||||
|
|
|
|||
|
|
@ -572,7 +572,7 @@ function handleSessionUpdate(
|
|||
}
|
||||
|
||||
function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): void {
|
||||
const sessionId = event.data?.sessionID ?? event.properties?.info?.id ?? event.properties?.sessionID ?? event.properties?.id
|
||||
const sessionId = event.data?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
log.info(`[SSE] Session deleted: ${sessionId}`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue