From e337efe97466540407dfd7301404b771caa42968 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 17:17:49 -0700 Subject: [PATCH] fix(canvas): propagate runtime through WORKSPACE_PROVISIONING event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The side-panel runtime pill read "unknown" for newly-deployed workspaces because canvas-events.ts created the node from WORKSPACE_PROVISIONING payload — and the payload only carried name + tier. No refetch filled the gap during provisioning, so the user saw "RUNTIME unknown" on the card even though the DB row had the real runtime set. Includes runtime in every WORKSPACE_PROVISIONING emitter: * handlers/workspace.go — initial create * handlers/workspace_restart.go — explicit restart, auto-restart, and crash-recovery resume loop * handlers/org_import.go — multi-workspace org imports Canvas-side: canvas-events.ts reads payload.runtime when creating the node; the provisioning test asserts the pill value is populated before any refetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/store/__tests__/canvas.test.ts | 5 ++++- canvas/src/store/canvas-events.ts | 1 + workspace-server/internal/handlers/org_import.go | 5 +++-- workspace-server/internal/handlers/workspace.go | 10 +++++++--- .../internal/handlers/workspace_restart.go | 9 +++++---- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/canvas/src/store/__tests__/canvas.test.ts b/canvas/src/store/__tests__/canvas.test.ts index 8e0675f1..f65e0481 100644 --- a/canvas/src/store/__tests__/canvas.test.ts +++ b/canvas/src/store/__tests__/canvas.test.ts @@ -269,7 +269,7 @@ describe("applyEvent", () => { makeMsg({ event: "WORKSPACE_PROVISIONING", workspace_id: "ws-new", - payload: { name: "Fresh", tier: 2 }, + payload: { name: "Fresh", tier: 2, runtime: "hermes" }, }) ); @@ -281,6 +281,9 @@ describe("applyEvent", () => { expect(newNode.data.name).toBe("Fresh"); expect(newNode.data.tier).toBe(2); expect(newNode.data.status).toBe("provisioning"); + // Runtime must flow through the provisioning event so the side-panel + // pill renders the real runtime instead of "unknown" until a refetch. + expect(newNode.data.runtime).toBe("hermes"); // Position is offset by existing node count * 40 expect(newNode.position.x).toBeGreaterThanOrEqual(0); expect(newNode.position.y).toBeGreaterThanOrEqual(0); diff --git a/canvas/src/store/canvas-events.ts b/canvas/src/store/canvas-events.ts index 8dfe9f73..765adef8 100644 --- a/canvas/src/store/canvas-events.ts +++ b/canvas/src/store/canvas-events.ts @@ -145,6 +145,7 @@ export function handleCanvasEvent( url: "", parentId: null, currentTask: "", + runtime: (msg.payload.runtime as string) ?? "", needsRestart: false, }, }, diff --git a/workspace-server/internal/handlers/org_import.go b/workspace-server/internal/handlers/org_import.go index 442f5836..8435f8a0 100644 --- a/workspace-server/internal/handlers/org_import.go +++ b/workspace-server/internal/handlers/org_import.go @@ -103,9 +103,10 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa log.Printf("Org import: canvas layout insert failed for %s: %v", ws.Name, err) } - // Broadcast + // Broadcast — include runtime so the canvas pill renders the right + // badge immediately instead of "unknown". h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{ - "name": ws.Name, "tier": tier, + "name": ws.Name, "tier": tier, "runtime": runtime, }) // Seed initial memories from workspace config or defaults (issue #1050). diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index b962c858..91ece238 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -254,10 +254,14 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { // Non-fatal: failures are logged but don't block workspace creation. seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace) - // Broadcast provisioning event + // Broadcast provisioning event. Include `runtime` so the canvas can + // populate the Runtime pill on the side panel immediately — without it + // the node lives as "runtime: unknown" until something refetches the + // workspace row (which nothing does during provisioning). h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{ - "name": payload.Name, - "tier": payload.Tier, + "name": payload.Name, + "tier": payload.Tier, + "runtime": payload.Runtime, }) // External workspaces: no container provisioning — just set the URL and mark online diff --git a/workspace-server/internal/handlers/workspace_restart.go b/workspace-server/internal/handlers/workspace_restart.go index 9b3b2bfa..9c12b22a 100644 --- a/workspace-server/internal/handlers/workspace_restart.go +++ b/workspace-server/internal/handlers/workspace_restart.go @@ -112,8 +112,9 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) { db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'provisioning', url = '', updated_at = now() WHERE id = $1`, id) h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", id, map[string]interface{}{ - "name": wsName, - "tier": tier, + "name": wsName, + "tier": tier, + "runtime": containerRuntime, }) // Read template from request body or try to find matching config @@ -331,7 +332,7 @@ func (h *WorkspaceHandler) RestartByID(workspaceID string) { db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'provisioning', url = '', updated_at = now() WHERE id = $1`, workspaceID) h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", workspaceID, map[string]interface{}{ - "name": wsName, "tier": tier, + "name": wsName, "tier": tier, "runtime": dbRuntime, }) // Runtime from DB — no more config file parsing @@ -463,7 +464,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) { db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'provisioning', updated_at = now() WHERE id = $1`, ws.id) h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISIONING", ws.id, map[string]interface{}{ - "name": ws.name, "tier": ws.tier, + "name": ws.name, "tier": ws.tier, "runtime": ws.runtime, }) payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime} // Dispatch to the matching provisioner (mirrors the Create +