fix(canvas): propagate runtime through WORKSPACE_PROVISIONING event

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 17:17:49 -07:00
parent dc50a1c775
commit e337efe974
5 changed files with 20 additions and 10 deletions

View File

@ -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);

View File

@ -145,6 +145,7 @@ export function handleCanvasEvent(
url: "",
parentId: null,
currentTask: "",
runtime: (msg.payload.runtime as string) ?? "",
needsRestart: false,
},
},

View File

@ -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).

View File

@ -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

View File

@ -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 +